style: 统一 Ruff 代码格式

This commit is contained in:
yml2213
2026-08-30 21:04:52 +08:00
parent c891ac982e
commit 47e19ed7b2
90 changed files with 5574 additions and 2350 deletions
+6 -1
View File
@@ -6,7 +6,12 @@ from .login_api_wgapi import WgapiLoginAPI
from .login_api_iframe import IframeLoginAPI from .login_api_iframe import IframeLoginAPI
from .email_verifier import EmailVerifier from .email_verifier import EmailVerifier
from .activity_client import DouyuActivityClient, DouyuActivityError from .activity_client import DouyuActivityClient, DouyuActivityError
from .recharge_api import FishFinRechargeClient, FishFinRechargeConfig, FishFinRechargeConfigError, FishFinRechargeError from .recharge_api import (
FishFinRechargeClient,
FishFinRechargeConfig,
FishFinRechargeConfigError,
FishFinRechargeError,
)
__all__ = [ __all__ = [
"DouyuLogin", "DouyuLogin",
+37 -33
View File
@@ -15,7 +15,9 @@ class CookieEnricher:
CSRF_API = "https://www.douyu.com/japi/carnival/nc/common/generateCsrf" CSRF_API = "https://www.douyu.com/japi/carnival/nc/common/generateCsrf"
CSRF_REFERER = "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298" CSRF_REFERER = "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298"
ACF_CCN_API = "https://www.douyu.com/curl/csrfNlApi/getCsrfCookie" ACF_CCN_API = "https://www.douyu.com/curl/csrfNlApi/getCsrfCookie"
ACF_CCN_REFERER = "https://www.douyu.com/pages/ord-task-center?clientType=web&panelSource=1&rid=0" ACF_CCN_REFERER = (
"https://www.douyu.com/pages/ord-task-center?clientType=web&panelSource=1&rid=0"
)
TIMEOUT = (5, 10) TIMEOUT = (5, 10)
RETRIES = 3 RETRIES = 3
@@ -47,25 +49,27 @@ class CookieEnricher:
logger.warning(f"补CK失败 {attempt}/{max_attempts}: {e}") logger.warning(f"补CK失败 {attempt}/{max_attempts}: {e}")
if attempt < max_attempts: if attempt < max_attempts:
self.sleep_interruptible(1) self.sleep_interruptible(1)
raise ValueError(f"已重试 {max_attempts} 次仍未补齐CK: {last_error}") from last_error raise ValueError(
f"已重试 {max_attempts} 次仍未补齐CK: {last_error}"
) from last_error
def generate_csrf_cookie(self) -> str: def generate_csrf_cookie(self) -> str:
"""访问 generateCsrf 接口,从 Set-Cookie 中同步 cvl_csrf_token。""" """访问 generateCsrf 接口,从 Set-Cookie 中同步 cvl_csrf_token。"""
logger.info("补齐CSRF Cookie...") logger.info("补齐CSRF Cookie...")
headers = { headers = {
'Accept': 'application/json, text/plain, */*', "Accept": "application/json, text/plain, */*",
'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5', "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5",
'Origin': 'https://www.douyu.com', "Origin": "https://www.douyu.com",
'Referer': self.CSRF_REFERER, "Referer": self.CSRF_REFERER,
'Sec-Fetch-Dest': 'empty', "Sec-Fetch-Dest": "empty",
'Sec-Fetch-Mode': 'cors', "Sec-Fetch-Mode": "cors",
'Sec-Fetch-Site': 'same-origin', "Sec-Fetch-Site": "same-origin",
# 覆盖登录接口的默认表单头,尽量贴近浏览器抓包。 # 覆盖登录接口的默认表单头,尽量贴近浏览器抓包。
'Content-Type': None, "Content-Type": None,
'X-Requested-With': None, "X-Requested-With": None,
} }
response = self.request( response = self.request(
'post', "post",
self.CSRF_API, self.CSRF_API,
headers=headers, headers=headers,
timeout=self.TIMEOUT, timeout=self.TIMEOUT,
@@ -82,11 +86,11 @@ class CookieEnricher:
preview = body[:200].replace("\n", "\\n") preview = body[:200].replace("\n", "\\n")
raise ValueError(f"生成CSRF失败: 响应不是有效 JSON: {preview}") from exc raise ValueError(f"生成CSRF失败: 响应不是有效 JSON: {preview}") from exc
if payload.get('error') != 0: if payload.get("error") != 0:
raise ValueError(f"生成CSRF失败: {payload.get('msg', '未知错误')}") raise ValueError(f"生成CSRF失败: {payload.get('msg', '未知错误')}")
cookies = self.session.cookies.get_dict() cookies = self.session.cookies.get_dict()
csrf_token = cookies.get('cvl_csrf_token', '') csrf_token = cookies.get("cvl_csrf_token", "")
if not csrf_token: if not csrf_token:
raise ValueError("生成CSRF失败: 响应没有 cvl_csrf_token") raise ValueError("生成CSRF失败: 响应没有 cvl_csrf_token")
@@ -97,25 +101,25 @@ class CookieEnricher:
"""访问 getCsrfCookie 接口,从 Set-Cookie 中同步 acf_ccn。""" """访问 getCsrfCookie 接口,从 Set-Cookie 中同步 acf_ccn。"""
logger.info("补齐 acf_ccn Cookie...") logger.info("补齐 acf_ccn Cookie...")
headers = { headers = {
'Accept': 'application/json, text/plain, */*', "Accept": "application/json, text/plain, */*",
'Accept-Language': 'zh-CN,zh;q=0.9', "Accept-Language": "zh-CN,zh;q=0.9",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
'Pragma': 'no-cache', "Pragma": "no-cache",
'Priority': 'u=1, i', "Priority": "u=1, i",
'Referer': self.ACF_CCN_REFERER, "Referer": self.ACF_CCN_REFERER,
'Sec-CH-UA': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', "Sec-CH-UA": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
'Sec-CH-UA-Mobile': '?0', "Sec-CH-UA-Mobile": "?0",
'Sec-CH-UA-Platform': '"macOS"', "Sec-CH-UA-Platform": '"macOS"',
'Sec-Fetch-Dest': 'empty', "Sec-Fetch-Dest": "empty",
'Sec-Fetch-Mode': 'cors', "Sec-Fetch-Mode": "cors",
'Sec-Fetch-Site': 'same-origin', "Sec-Fetch-Site": "same-origin",
# 该接口抓包没有 Origin、表单 Content-Type 和 X-Requested-With。 # 该接口抓包没有 Origin、表单 Content-Type 和 X-Requested-With。
'Origin': None, "Origin": None,
'Content-Type': None, "Content-Type": None,
'X-Requested-With': None, "X-Requested-With": None,
} }
response = self.request( response = self.request(
'get', "get",
self.ACF_CCN_API, self.ACF_CCN_API,
headers=headers, headers=headers,
timeout=self.TIMEOUT, timeout=self.TIMEOUT,
@@ -123,10 +127,10 @@ class CookieEnricher:
response.raise_for_status() response.raise_for_status()
cookies = self.session.cookies.get_dict() cookies = self.session.cookies.get_dict()
acf_ccn = cookies.get('acf_ccn', '') or response.cookies.get('acf_ccn', '') acf_ccn = cookies.get("acf_ccn", "") or response.cookies.get("acf_ccn", "")
if acf_ccn and not cookies.get('acf_ccn'): if acf_ccn and not cookies.get("acf_ccn"):
# 极少数情况下响应 Cookie 未合入 get_dict,手动补到斗鱼域名下。 # 极少数情况下响应 Cookie 未合入 get_dict,手动补到斗鱼域名下。
self.session.cookies.set('acf_ccn', acf_ccn, domain='.douyu.com', path='/') self.session.cookies.set("acf_ccn", acf_ccn, domain=".douyu.com", path="/")
if not acf_ccn: if not acf_ccn:
raise ValueError("补齐 acf_ccn 失败: 响应没有 acf_ccn") raise ValueError("补齐 acf_ccn 失败: 响应没有 acf_ccn")
+9 -9
View File
@@ -25,21 +25,21 @@ DOUYU_RC4_KEY = "7TkbRSEWvVWebXbr"
def md5(text: str) -> str: def md5(text: str) -> str:
"""MD5加密""" """MD5加密"""
return hashlib.md5(text.encode('utf-8')).hexdigest() return hashlib.md5(text.encode("utf-8")).hexdigest()
def rsa_encrypt(text: str, public_key: str = DOUYU_RSA_PUBLIC_KEY) -> str: def rsa_encrypt(text: str, public_key: str = DOUYU_RSA_PUBLIC_KEY) -> str:
"""RSA加密""" """RSA加密"""
key = RSA.import_key(public_key) key = RSA.import_key(public_key)
cipher = PKCS1_v1_5.new(key) cipher = PKCS1_v1_5.new(key)
encrypted = cipher.encrypt(text.encode('utf-8')) encrypted = cipher.encrypt(text.encode("utf-8"))
return base64.b64encode(encrypted).decode('utf-8') return base64.b64encode(encrypted).decode("utf-8")
def aes_encrypt(text: str, key: str = DOUYU_AES_KEY) -> str: def aes_encrypt(text: str, key: str = DOUYU_AES_KEY) -> str:
"""AES加密""" """AES加密"""
key_bytes = key.encode('utf-8') key_bytes = key.encode("utf-8")
text_bytes = text.encode('utf-8') text_bytes = text.encode("utf-8")
# 填充到16的倍数 # 填充到16的倍数
padding_len = 16 - (len(text_bytes) % 16) padding_len = 16 - (len(text_bytes) % 16)
@@ -47,7 +47,7 @@ def aes_encrypt(text: str, key: str = DOUYU_AES_KEY) -> str:
cipher = AES.new(key_bytes, AES.MODE_ECB) cipher = AES.new(key_bytes, AES.MODE_ECB)
encrypted = cipher.encrypt(text_bytes) encrypted = cipher.encrypt(text_bytes)
return base64.b64encode(encrypted).decode('utf-8') return base64.b64encode(encrypted).decode("utf-8")
def encrypt_username(username: str) -> str: def encrypt_username(username: str) -> str:
@@ -62,7 +62,7 @@ def encrypt_password(password: str) -> str:
def encrypt_nickname_or_phone(text: str) -> str: def encrypt_nickname_or_phone(text: str) -> str:
"""加密昵称或手机号""" """加密昵称或手机号"""
key = DOUYU_RC4_KEY.encode('utf-8') key = DOUYU_RC4_KEY.encode("utf-8")
cipher = ARC4.new(key) cipher = ARC4.new(key)
encrypted = cipher.encrypt(text.encode('utf-8')) encrypted = cipher.encrypt(text.encode("utf-8"))
return base64.b64encode(encrypted).decode('utf-8') return base64.b64encode(encrypted).decode("utf-8")
+20 -18
View File
@@ -47,7 +47,9 @@ class LoginAPIStrategy(ABC):
... ...
@abstractmethod @abstractmethod
def build_first_login_data(self, username: str, password: str, referer: str) -> dict: def build_first_login_data(
self, username: str, password: str, referer: str
) -> dict:
"""构建第一次登录请求参数(获取极验参数)""" """构建第一次登录请求参数(获取极验参数)"""
... ...
@@ -85,11 +87,11 @@ class LoginAPIStrategy(ABC):
Returns: Returns:
(gt, challenge, code_token) (gt, challenge, code_token)
""" """
geetest_data = payload.get('data', {}).get('geetest', {}) geetest_data = payload.get("data", {}).get("geetest", {})
code_data = geetest_data.get('code_data', {}) code_data = geetest_data.get("code_data", {})
gt = code_data.get('gt', '') gt = code_data.get("gt", "")
challenge = code_data.get('challenge', '') challenge = code_data.get("challenge", "")
code_token = geetest_data.get('code_token', '') code_token = geetest_data.get("code_token", "")
return gt, challenge, code_token return gt, challenge, code_token
def extract_remote_code(self, payload: dict) -> str: def extract_remote_code(self, payload: dict) -> str:
@@ -101,7 +103,7 @@ class LoginAPIStrategy(ABC):
Returns: Returns:
remote_code 字符串 remote_code 字符串
""" """
return payload.get('data', {}).get('remoteLogin', {}).get('code', '') return payload.get("data", {}).get("remoteLogin", {}).get("code", "")
def extract_login_url(self, payload: dict) -> str: def extract_login_url(self, payload: dict) -> str:
"""从验证码响应提取登录 URL """从验证码响应提取登录 URL
@@ -112,27 +114,27 @@ class LoginAPIStrategy(ABC):
Returns: Returns:
登录回调 URL 登录回调 URL
""" """
data = payload.get('data', {}) data = payload.get("data", {})
if not isinstance(data, dict): if not isinstance(data, dict):
return '' return ""
# `remoteLogin` 与“跳过绑定手机号”完成后都会返回同类回调地址, # `remoteLogin` 与“跳过绑定手机号”完成后都会返回同类回调地址,
# 但不同版本接口的字段命名不完全一致。 # 但不同版本接口的字段命名不完全一致。
for key in ('url', 'loginUrl', 'login_url', 'redirectUrl', 'redirect_url'): for key in ("url", "loginUrl", "login_url", "redirectUrl", "redirect_url"):
value = data.get(key) value = data.get(key)
if isinstance(value, str) and value: if isinstance(value, str) and value:
return value return value
return '' return ""
def extract_mobile_bind_unique_key(self, payload: dict) -> str: def extract_mobile_bind_unique_key(self, payload: dict) -> str:
"""提取服务端要求绑定手机号时返回的一次性继续登录标识。""" """提取服务端要求绑定手机号时返回的一次性继续登录标识。"""
data = payload.get('data', {}) data = payload.get("data", {})
if not isinstance(data, dict): if not isinstance(data, dict):
return '' return ""
def find_unique_key(value: object) -> str: def find_unique_key(value: object) -> str:
if isinstance(value, dict): if isinstance(value, dict):
unique_key = value.get('uniqueKey') or value.get('unique_key') unique_key = value.get("uniqueKey") or value.get("unique_key")
if isinstance(unique_key, str) and unique_key: if isinstance(unique_key, str) and unique_key:
return unique_key return unique_key
for child in value.values(): for child in value.values():
@@ -144,14 +146,14 @@ class LoginAPIStrategy(ABC):
result = find_unique_key(child) result = find_unique_key(child)
if result: if result:
return result return result
return '' return ""
return find_unique_key(data) return find_unique_key(data)
def build_skip_mobile_bind_data(self, unique_key: str) -> dict: def build_skip_mobile_bind_data(self, unique_key: str) -> dict:
"""构建跳过手机号绑定、继续完成网页登录的请求参数。""" """构建跳过手机号绑定、继续完成网页登录的请求参数。"""
return { return {
'type': '3', "type": "3",
'uniqueKey': unique_key, "uniqueKey": unique_key,
'biz_type': '1', "biz_type": "1",
} }
+44 -42
View File
@@ -42,9 +42,11 @@ class IframeLoginAPI(LoginAPIStrategy):
def encrypt_username(self, username: str) -> str: def encrypt_username(self, username: str) -> str:
"""RC4 加密后 URL 编码""" """RC4 加密后 URL 编码"""
encrypted = encrypt_nickname_or_phone(username) encrypted = encrypt_nickname_or_phone(username)
return urllib.parse.quote(encrypted, safe='') return urllib.parse.quote(encrypted, safe="")
def build_first_login_data(self, username: str, password: str, referer: str) -> dict: def build_first_login_data(
self, username: str, password: str, referer: str
) -> dict:
"""构建第一次登录参数(获取极验参数) """构建第一次登录参数(获取极验参数)
注:第一次登录使用 wgapi 接口,所以用户名用 base64 编码(而非 URL 编码)。 注:第一次登录使用 wgapi 接口,所以用户名用 base64 编码(而非 URL 编码)。
@@ -52,17 +54,17 @@ class IframeLoginAPI(LoginAPIStrategy):
# 第一次登录用 wgapi 接口,需要 base64 编码的用户名 # 第一次登录用 wgapi 接口,需要 base64 编码的用户名
encrypted_username = encrypt_nickname_or_phone(username) encrypted_username = encrypt_nickname_or_phone(username)
return { return {
'type': '1', "type": "1",
'nicknameOrPhoneEncrypt': encrypted_username, "nicknameOrPhoneEncrypt": encrypted_username,
'password': encrypt_password(password), "password": encrypt_password(password),
'biz_type': '1', "biz_type": "1",
'room_id': '0', "room_id": "0",
'redirect_url': referer, "redirect_url": referer,
't': str(int(time.time() * 1000)), "t": str(int(time.time() * 1000)),
'client_id': '1', "client_id": "1",
'did': '', "did": "",
'lang': '', "lang": "",
'isMultiAccount': '0', "isMultiAccount": "0",
} }
def build_second_login_data( def build_second_login_data(
@@ -81,46 +83,46 @@ class IframeLoginAPI(LoginAPIStrategy):
登录使用 wgapi 接口,参数格式与 wgapi 策略一致。 登录使用 wgapi 接口,参数格式与 wgapi 策略一致。
""" """
# seccode 格式: validate|jordanrequests 会自动 URL 编码) # seccode 格式: validate|jordanrequests 会自动 URL 编码)
seccode_value = validate + '|jordan' seccode_value = validate + "|jordan"
# 使用 base64 编码(wgapi 接口要求),而不是 URL 编码 # 使用 base64 编码(wgapi 接口要求),而不是 URL 编码
encrypted_username = encrypt_nickname_or_phone(username) encrypted_username = encrypt_nickname_or_phone(username)
return { return {
'type': '1', "type": "1",
'nicknameOrPhoneEncrypt': encrypted_username, "nicknameOrPhoneEncrypt": encrypted_username,
'password': encrypt_password(password), "password": encrypt_password(password),
'room_id': '0', "room_id": "0",
'code_type': '1', "code_type": "1",
'code_token': code_token, "code_token": code_token,
'gt_version': 'v3', "gt_version": "v3",
'geetest_challenge': challenge, "geetest_challenge": challenge,
'geetest_validate': validate, "geetest_validate": validate,
'geetest_seccode': seccode_value, "geetest_seccode": seccode_value,
'code_data[geetest_challenge]': challenge, "code_data[geetest_challenge]": challenge,
'code_data[geetest_validate]': validate, "code_data[geetest_validate]": validate,
'code_data[geetest_seccode]': seccode_value, "code_data[geetest_seccode]": seccode_value,
'code_data[gt_version]': 'v3', "code_data[gt_version]": "v3",
'code_data[code]': '', "code_data[code]": "",
'redirect_url': referer, "redirect_url": referer,
't': str(int(time.time() * 1000)), "t": str(int(time.time() * 1000)),
'client_id': '1', "client_id": "1",
'did': '', "did": "",
'lang': '', "lang": "",
'isMultiAccount': '0', "isMultiAccount": "0",
'biz_type': '1', "biz_type": "1",
} }
def build_send_email_data(self, code: str) -> dict: def build_send_email_data(self, code: str) -> dict:
"""构建发送邮箱验证参数""" """构建发送邮箱验证参数"""
return {'cacheId': code} return {"cacheId": code}
def build_verify_data(self, code: str, captcha: str) -> dict: def build_verify_data(self, code: str, captcha: str) -> dict:
"""构建提交验证码参数""" """构建提交验证码参数"""
return { return {
'veify_type': '2', # e 语言原文如此,斗鱼接口拼写 "veify_type": "2", # e 语言原文如此,斗鱼接口拼写
'phoneCaptcha': captcha, "phoneCaptcha": captcha,
'client_id': '1', "client_id": "1",
'isMultiAccount': '0', "isMultiAccount": "0",
'cacheId': code, "cacheId": code,
} }
+44 -42
View File
@@ -36,20 +36,22 @@ class WgapiLoginAPI(LoginAPIStrategy):
"""RC4 加密后 base64 编码""" """RC4 加密后 base64 编码"""
return encrypt_nickname_or_phone(username) return encrypt_nickname_or_phone(username)
def build_first_login_data(self, username: str, password: str, referer: str) -> dict: def build_first_login_data(
self, username: str, password: str, referer: str
) -> dict:
"""构建第一次登录参数""" """构建第一次登录参数"""
return { return {
'type': '1', "type": "1",
'nicknameOrPhoneEncrypt': self.encrypt_username(username), "nicknameOrPhoneEncrypt": self.encrypt_username(username),
'password': encrypt_password(password), "password": encrypt_password(password),
'biz_type': '1', "biz_type": "1",
'room_id': '0', "room_id": "0",
'redirect_url': referer, "redirect_url": referer,
't': str(int(time.time() * 1000)), "t": str(int(time.time() * 1000)),
'client_id': '1', "client_id": "1",
'did': '', "did": "",
'lang': '', "lang": "",
'isMultiAccount': '0', "isMultiAccount": "0",
} }
def build_second_login_data( def build_second_login_data(
@@ -65,44 +67,44 @@ class WgapiLoginAPI(LoginAPIStrategy):
) -> dict: ) -> dict:
"""构建第二次登录参数(带极验验证)""" """构建第二次登录参数(带极验验证)"""
return { return {
'type': '1', "type": "1",
'nicknameOrPhoneEncrypt': self.encrypt_username(username), "nicknameOrPhoneEncrypt": self.encrypt_username(username),
'password': encrypt_password(password), "password": encrypt_password(password),
'room_id': '0', "room_id": "0",
'code_type': '1', "code_type": "1",
'code_token': code_token, "code_token": code_token,
'gt_version': 'v3', "gt_version": "v3",
'geetest_challenge': challenge, "geetest_challenge": challenge,
'geetest_validate': validate, "geetest_validate": validate,
'geetest_seccode': seccode, "geetest_seccode": seccode,
'code_data[geetest_challenge]': challenge, "code_data[geetest_challenge]": challenge,
'code_data[geetest_validate]': validate, "code_data[geetest_validate]": validate,
'code_data[geetest_seccode]': seccode, "code_data[geetest_seccode]": seccode,
'code_data[gt_version]': 'v3', "code_data[gt_version]": "v3",
'code_data[code]': '', "code_data[code]": "",
'redirect_url': referer, "redirect_url": referer,
't': str(int(time.time() * 1000)), "t": str(int(time.time() * 1000)),
'client_id': '1', "client_id": "1",
'did': '', "did": "",
'lang': '', "lang": "",
'isMultiAccount': '0', "isMultiAccount": "0",
'biz_type': '1', "biz_type": "1",
} }
def build_send_email_data(self, code: str) -> dict: def build_send_email_data(self, code: str) -> dict:
"""构建发送邮箱验证参数""" """构建发送邮箱验证参数"""
return { return {
'code': code, "code": code,
'client_id': '1', "client_id": "1",
} }
def build_verify_data(self, code: str, captcha: str) -> dict: def build_verify_data(self, code: str, captcha: str) -> dict:
"""构建提交验证码参数""" """构建提交验证码参数"""
return { return {
'verify_type': '2', "verify_type": "2",
'captcha': captcha, "captcha": captcha,
'isMultiAccount': '0', "isMultiAccount": "0",
'code': code, "code": code,
'client_id': '1', "client_id": "1",
'redirect_url': '//www.douyu.com/api/passport/login', "redirect_url": "//www.douyu.com/api/passport/login",
} }
+4 -4
View File
@@ -24,7 +24,7 @@ def parse_proxy_response(text: str) -> tuple[list[str], Optional[str]]:
code = data.get("code") code = data.get("code")
msg = data.get("msg", "") or "" msg = data.get("msg", "") or ""
if code != 0 and ("白名单" in msg or "添加白名单" in msg): if code != 0 and ("白名单" in msg or "添加白名单" in msg):
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', msg) ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", msg)
if ip_match: if ip_match:
return [], ip_match.group(1) return [], ip_match.group(1)
@@ -46,12 +46,12 @@ def parse_proxy_response(text: str) -> tuple[list[str], Optional[str]]:
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
pass pass
if '添加白名单' in text or '白名单' in text: if "添加白名单" in text or "白名单" in text:
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text) ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", text)
if ip_match: if ip_match:
return [], ip_match.group(1) return [], ip_match.group(1)
matches = re.findall(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text) matches = re.findall(r"(\d+\.\d+\.\d+\.\d+):(\d+)", text)
proxies = [f"http://{ip}:{port}" for ip, port in matches] proxies = [f"http://{ip}:{port}" for ip, port in matches]
if proxies: if proxies:
return proxies, None return proxies, None
+11 -4
View File
@@ -67,7 +67,10 @@ class XiequAdapter(BaseWhitelistAdapter):
# 映射到统一格式 # 映射到统一格式
return [ return [
{"ip": r.get("IP", r.get("ip", "")), "memo": r.get("MEMO", r.get("memo", ""))} {
"ip": r.get("IP", r.get("ip", "")),
"memo": r.get("MEMO", r.get("memo", "")),
}
for r in data for r in data
if r.get("IP") or r.get("ip") if r.get("IP") or r.get("ip")
] ]
@@ -97,7 +100,7 @@ class XiequAdapter(BaseWhitelistAdapter):
# 频率限制,等待后重试一次 # 频率限制,等待后重试一次
if retry and ("频率过快" in text or "稍后" in text): if retry and ("频率过快" in text or "稍后" in text):
wait = 5 wait = 5
match = re.search(r'(\d+)\s*秒', text) match = re.search(r"(\d+)\s*秒", text)
if match: if match:
wait = int(match.group(1)) wait = int(match.group(1))
logger.info(f"白名单添加被限流,等待 {wait} 秒后重试...") logger.info(f"白名单添加被限流,等待 {wait} 秒后重试...")
@@ -147,9 +150,13 @@ class XiequAdapter(BaseWhitelistAdapter):
try: try:
records = self.get_whitelist() records = self.get_whitelist()
count = len(records) count = len(records)
my_records = [r for r in records if r.get("memo", "").startswith(self.MEMO_PREFIX)] my_records = [
r for r in records if r.get("memo", "").startswith(self.MEMO_PREFIX)
]
msg = f"连接成功,白名单共 {count} 条记录,其中本机相关 {len(my_records)}" msg = (
f"连接成功,白名单共 {count} 条记录,其中本机相关 {len(my_records)}"
)
logger.info(msg) logger.info(msg)
return True, msg return True, msg
+1
View File
@@ -51,6 +51,7 @@ class XkdailiAdapter(BaseWhitelistAdapter):
} }
base_params.update(params) base_params.update(params)
from urllib.parse import urlencode from urllib.parse import urlencode
return f"{self.BASE_URL}?{urlencode(base_params)}" return f"{self.BASE_URL}?{urlencode(base_params)}"
def _parse_response(self, text: str) -> tuple[bool, str]: def _parse_response(self, text: str) -> tuple[bool, str]:
+41 -30
View File
@@ -17,11 +17,9 @@ LogFunc = Callable[[str, str], None]
class WhitelistSyncer(Protocol): class WhitelistSyncer(Protocol):
"""代理解析流程需要的白名单能力。""" """代理解析流程需要的白名单能力。"""
def sync_ip(self, ip: str) -> tuple[bool, str]: def sync_ip(self, ip: str) -> tuple[bool, str]: ...
...
def get_local_exit_ip(self) -> Optional[str]: def get_local_exit_ip(self) -> Optional[str]: ...
...
class ProxyResolver: class ProxyResolver:
@@ -62,14 +60,16 @@ class ProxyResolver:
return return
log_method = getattr( log_method = getattr(
logger, logger,
level if level in ('debug', 'info', 'warning', 'error', 'success') else 'info', level
if level in ("debug", "info", "warning", "error", "success")
else "info",
logger.info, logger.info,
) )
log_method(message) log_method(message)
def _sync_ip(self, ip: str) -> tuple[bool, str]: def _sync_ip(self, ip: str) -> tuple[bool, str]:
if not self.whitelist_syncer: if not self.whitelist_syncer:
return False, '未配置白名单 UID/UKEY' return False, "未配置白名单 UID/UKEY"
ok, sync_msg = self.whitelist_syncer.sync_ip(ip) ok, sync_msg = self.whitelist_syncer.sync_ip(ip)
if ok: if ok:
self._last_synced_ip = ip self._last_synced_ip = ip
@@ -82,14 +82,14 @@ class ProxyResolver:
local_ip = self.whitelist_syncer.get_local_exit_ip() local_ip = self.whitelist_syncer.get_local_exit_ip()
if local_ip and local_ip != self._last_synced_ip: if local_ip and local_ip != self._last_synced_ip:
self._log('info', f"[尝试 {attempt}] 检测出口IP: {local_ip},同步白名单...") self._log("info", f"[尝试 {attempt}] 检测出口IP: {local_ip},同步白名单...")
ok, sync_msg = self._sync_ip(local_ip) ok, sync_msg = self._sync_ip(local_ip)
if ok: if ok:
self._log('info', f"白名单同步成功: {sync_msg}") self._log("info", f"白名单同步成功: {sync_msg}")
else: else:
self._log('warning', f"白名单同步失败: {sync_msg}") self._log("warning", f"白名单同步失败: {sync_msg}")
elif local_ip == self._last_synced_ip: elif local_ip == self._last_synced_ip:
self._log('debug', f"[尝试 {attempt}] 出口IP未变: {local_ip}") self._log("debug", f"[尝试 {attempt}] 出口IP未变: {local_ip}")
def fetch_verified( def fetch_verified(
self, self,
@@ -107,18 +107,18 @@ class ProxyResolver:
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):
if self._is_stopped(): if self._is_stopped():
return None, '任务已停止' return None, "任务已停止"
if attempt > 1: if attempt > 1:
delay = min(attempt - 1, 2) delay = min(attempt - 1, 2)
if self.log_func: if self.log_func:
self._log('info', f'等待 {delay}s 后重试...') self._log("info", f"等待 {delay}s 后重试...")
if self._wait_or_stopped(delay): if self._wait_or_stopped(delay):
return None, '任务已停止' return None, "任务已停止"
self._sync_local_exit_ip_if_needed(attempt) self._sync_local_exit_ip_if_needed(attempt)
if self._is_stopped(): if self._is_stopped():
return None, '任务已停止' return None, "任务已停止"
self._log('info', f'代理预检 {attempt}/{max_attempts}: 正在获取代理') self._log("info", f"代理预检 {attempt}/{max_attempts}: 正在获取代理")
try: try:
response = requests.get(self.api_url, timeout=10) response = requests.get(self.api_url, timeout=10)
@@ -128,38 +128,49 @@ class ProxyResolver:
proxy_urls, whitelist_ip = parse_proxy_response(text) proxy_urls, whitelist_ip = parse_proxy_response(text)
if proxy_urls: if proxy_urls:
self._log('info', f'获取到 {len(proxy_urls)} 个代理,并发验证') self._log("info", f"获取到 {len(proxy_urls)} 个代理,并发验证")
available, msg = verify_proxies_concurrent(proxy_urls, return_all=return_all) available, msg = verify_proxies_concurrent(
proxy_urls, return_all=return_all
)
if available: if available:
if not return_all: if not return_all:
self._log('success', f'代理预检成功: {available}') self._log("success", f"代理预检成功: {available}")
return available, msg return available, msg
last_error = msg last_error = msg
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}") self._log(
"warning", f"代理预检 {attempt}/{max_attempts}: {last_error}"
)
continue continue
if whitelist_ip and self.whitelist_syncer: if whitelist_ip and self.whitelist_syncer:
if self.sync_whitelist_once and self._has_synced_whitelist: if self.sync_whitelist_once and self._has_synced_whitelist:
last_error = f'白名单已同步但代理API仍返回白名单错误: {whitelist_ip}' last_error = (
self._log('warning', last_error) f"白名单已同步但代理API仍返回白名单错误: {whitelist_ip}"
)
self._log("warning", last_error)
continue continue
self._log('warning', f'代理需要白名单IP: {whitelist_ip},自动同步...') self._log(
"warning", f"代理需要白名单IP: {whitelist_ip},自动同步..."
)
ok, sync_msg = self._sync_ip(whitelist_ip) ok, sync_msg = self._sync_ip(whitelist_ip)
self._log('success' if ok else 'error', f'白名单同步: {sync_msg}') self._log("success" if ok else "error", f"白名单同步: {sync_msg}")
if ok: if ok:
self._log('info', '白名单已更新,立即重试...') self._log("info", "白名单已更新,立即重试...")
continue continue
return None, f'白名单同步失败: {sync_msg}' return None, f"白名单同步失败: {sync_msg}"
last_error = '代理API响应无法解析' last_error = "代理API响应无法解析"
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}") self._log(
"warning",
f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}",
)
except Exception as exc: except Exception as exc:
last_error = f'代理API请求失败: {exc}' last_error = f"代理API请求失败: {exc}"
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}") self._log("warning", f"代理预检 {attempt}/{max_attempts}: {last_error}")
return None, f'代理预检失败({max_attempts}次尝试均失败): {last_error}' return None, f"代理预检失败({max_attempts}次尝试均失败): {last_error}"
def resolve_working_proxy( def resolve_working_proxy(
+18 -14
View File
@@ -16,24 +16,24 @@ def verify_proxy_url(proxy_url: str, timeout: tuple = (3, 5)) -> tuple[bool, str
Returns: Returns:
(是否可用, 消息) (是否可用, 消息)
""" """
proxies = {'http': proxy_url, 'https': proxy_url} proxies = {"http": proxy_url, "https": proxy_url}
# ── 斗鱼主站可达性验证 ── # ── 斗鱼主站可达性验证 ──
try: try:
response = requests.get( response = requests.get(
'https://www.douyu.com', "https://www.douyu.com",
proxies=proxies, proxies=proxies,
timeout=timeout, timeout=timeout,
headers={'User-Agent': 'Mozilla/5.0'}, headers={"User-Agent": "Mozilla/5.0"},
) )
response.raise_for_status() response.raise_for_status()
return True, '代理可用 → 斗鱼可达' return True, "代理可用 → 斗鱼可达"
except Exception as exc: except Exception as exc:
err_msg = str(exc) err_msg = str(exc)
if 'Tunnel connection failed' in err_msg or '503' in err_msg: if "Tunnel connection failed" in err_msg or "503" in err_msg:
detail = '代理拒绝连接(白名单可能未生效)' detail = "代理拒绝连接(白名单可能未生效)"
elif 'timed out' in err_msg.lower(): elif "timed out" in err_msg.lower():
detail = '连接超时' detail = "连接超时"
else: else:
detail = type(exc).__name__ detail = type(exc).__name__
logger.debug(f"代理验证失败 [{proxy_url}]: 斗鱼主站不可达: {detail}") logger.debug(f"代理验证失败 [{proxy_url}]: 斗鱼主站不可达: {detail}")
@@ -54,7 +54,7 @@ def verify_proxies_concurrent(
return_all=True: (可用代理 URL 列表或 None, 消息) return_all=True: (可用代理 URL 列表或 None, 消息)
""" """
if not proxy_urls: if not proxy_urls:
return None, '无代理可验证' return None, "无代理可验证"
if len(proxy_urls) == 1: if len(proxy_urls) == 1:
ok, msg = verify_proxy_url(proxy_urls[0], timeout) ok, msg = verify_proxy_url(proxy_urls[0], timeout)
@@ -64,7 +64,9 @@ def verify_proxies_concurrent(
if return_all: if return_all:
available: list[str] = [] available: list[str] = []
with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor: with ThreadPoolExecutor(
max_workers=min(max_workers, len(proxy_urls))
) as executor:
future_map = { future_map = {
executor.submit(verify_proxy_url, proxy, timeout): proxy executor.submit(verify_proxy_url, proxy, timeout): proxy
for proxy in proxy_urls for proxy in proxy_urls
@@ -77,9 +79,11 @@ def verify_proxies_concurrent(
except Exception: except Exception:
continue continue
if available: if available:
logger.success(f"并发验证找到 {len(available)}/{len(proxy_urls)} 个可用代理") logger.success(
return available, f'找到 {len(available)} 个可用代理' f"并发验证找到 {len(available)}/{len(proxy_urls)} 个可用代理"
return None, f'{len(proxy_urls)} 个代理均不可用' )
return available, f"找到 {len(available)} 个可用代理"
return None, f"{len(proxy_urls)} 个代理均不可用"
with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor: with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor:
future_map = { future_map = {
@@ -99,4 +103,4 @@ def verify_proxies_concurrent(
except Exception: except Exception:
continue continue
return None, f'{len(proxy_urls)} 个代理均不可用' return None, f"{len(proxy_urls)} 个代理均不可用"
+53 -28
View File
@@ -44,7 +44,8 @@ class FishFinRechargeConfig:
app_key=os.getenv("FISH_FIN_RECHARGE_APP_KEY", "").strip(), app_key=os.getenv("FISH_FIN_RECHARGE_APP_KEY", "").strip(),
notify_url=os.getenv("FISH_FIN_RECHARGE_NOTIFY_URL", "").strip(), notify_url=os.getenv("FISH_FIN_RECHARGE_NOTIFY_URL", "").strip(),
timeout=(8, timeout), timeout=(8, timeout),
debug=os.getenv("FISH_FIN_RECHARGE_DEBUG", "false").strip().lower() in {"1", "true", "yes"}, debug=os.getenv("FISH_FIN_RECHARGE_DEBUG", "false").strip().lower()
in {"1", "true", "yes"},
) )
def validate(self) -> None: def validate(self) -> None:
@@ -86,7 +87,9 @@ class FishFinRechargeClient:
def _sign_value(value: Any) -> str: def _sign_value(value: Any) -> str:
"""将参数转为待签名文本;对象按稳定紧凑 JSON 表示。""" """将参数转为待签名文本;对象按稳定紧凑 JSON 表示。"""
if isinstance(value, (dict, list, tuple)): if isinstance(value, (dict, list, tuple)):
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) return json.dumps(
value, ensure_ascii=False, separators=(",", ":"), sort_keys=True
)
if isinstance(value, bool): if isinstance(value, bool):
return "true" if value else "false" return "true" if value else "false"
return str(value) return str(value)
@@ -143,7 +146,9 @@ class FishFinRechargeClient:
received = str(payload.get("sign") or "").strip().lower() received = str(payload.get("sign") or "").strip().lower()
return bool(received) and received == self.sign(payload, method) return bool(received) and received == self.sign(payload, method)
def _request(self, method: str, path: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]: def _request(
self, method: str, path: str, params: Mapping[str, Any] | None = None
) -> dict[str, Any]:
"""补齐公共参数、签名并执行一次 JSON 请求。""" """补齐公共参数、签名并执行一次 JSON 请求。"""
request_params: dict[str, Any] = { request_params: dict[str, Any] = {
"app_id": self.config.app_id, "app_id": self.config.app_id,
@@ -167,27 +172,37 @@ class FishFinRechargeClient:
if key != "sign" if key != "sign"
}, },
# 只记录摘要,便于关联排查而不暴露可重放的签名。 # 只记录摘要,便于关联排查而不暴露可重放的签名。
"sign_digest": hashlib.sha256(request_params["sign"].encode("utf-8")).hexdigest()[:12], "sign_digest": hashlib.sha256(
request_params["sign"].encode("utf-8")
).hexdigest()[:12],
} }
if self.config.debug: if self.config.debug:
sign_params = self.normalized_params(request_params) sign_params = self.normalized_params(request_params)
sign_query = "&".join(f"{key}={sign_params[key]}" for key in sorted(sign_params)) sign_query = "&".join(
trace_event.update({ f"{key}={sign_params[key]}" for key in sorted(sign_params)
"url": url, )
"content_type": "application/json", trace_event.update(
"json_body": self._debug_payload(request_params), {
"sign_params": sign_params, "url": url,
# 不把可重放签名原文或 AppSecret 写入日志,只记录待签名串摘要。 "content_type": "application/json",
"sign_source_digest": hashlib.sha256( "json_body": self._debug_payload(request_params),
f"{sign_query}{method.upper()}".encode("utf-8") "sign_params": sign_params,
).hexdigest()[:12], # 不把可重放签名原文或 AppSecret 写入日志,只记录待签名串摘要。
}) "sign_source_digest": hashlib.sha256(
f"{sign_query}{method.upper()}".encode("utf-8")
).hexdigest()[:12],
}
)
self.trace(trace_event) self.trace(trace_event)
try: try:
if method.upper() == "GET": if method.upper() == "GET":
response = self.session.get(url, params=request_params, timeout=self.config.timeout) response = self.session.get(
url, params=request_params, timeout=self.config.timeout
)
else: else:
response = self.session.post(url, json=request_params, timeout=self.config.timeout) response = self.session.post(
url, json=request_params, timeout=self.config.timeout
)
response.raise_for_status() response.raise_for_status()
payload = response.json() payload = response.json()
except requests.RequestException as exc: except requests.RequestException as exc:
@@ -205,18 +220,22 @@ class FishFinRechargeClient:
"message": payload.get("msg") or payload.get("message") or "", "message": payload.get("msg") or payload.get("message") or "",
"out_order_id": self._response_value(payload, "out_order_id", "outOrderId"), "out_order_id": self._response_value(payload, "out_order_id", "outOrderId"),
"order_id": self._response_value(payload, "order_id", "orderId"), "order_id": self._response_value(payload, "order_id", "orderId"),
"order_status": self._response_value(payload, "order_status", "orderStatus"), "order_status": self._response_value(
payload, "order_status", "orderStatus"
),
"fail_reason": self._response_value(payload, "fail_reason", "failReason"), "fail_reason": self._response_value(payload, "fail_reason", "failReason"),
} }
if self.config.debug: if self.config.debug:
trace_event.update({ trace_event.update(
"response_headers": { {
key: value "response_headers": {
for key, value in response.headers.items() key: value
if key.lower() in {"content-type", "x-request-id", "request-id"} for key, value in response.headers.items()
}, if key.lower() in {"content-type", "x-request-id", "request-id"}
"response_body": self._debug_payload(payload), },
}) "response_body": self._debug_payload(payload),
}
)
self.trace(trace_event) self.trace(trace_event)
return payload return payload
@@ -263,7 +282,11 @@ class FishFinRechargeClient:
raise ValueError("product_id 不能为空") raise ValueError("product_id 不能为空")
if not isinstance(buy_num, int) or isinstance(buy_num, bool) or buy_num < 1: if not isinstance(buy_num, int) or isinstance(buy_num, bool) or buy_num < 1:
raise ValueError("buy_num 必须是不小于 1 的整数") raise ValueError("buy_num 必须是不小于 1 的整数")
if not isinstance(order_type, int) or isinstance(order_type, bool) or order_type not in {0, 1, 2, 3}: if (
not isinstance(order_type, int)
or isinstance(order_type, bool)
or order_type not in {0, 1, 2, 3}
):
raise ValueError("order_type 必须是 0 至 3 的整数") raise ValueError("order_type 必须是 0 至 3 的整数")
if not isinstance(recharge_arg, list) or not recharge_arg: if not isinstance(recharge_arg, list) or not recharge_arg:
raise ValueError("recharge_arg 必须是非空数组") raise ValueError("recharge_arg 必须是非空数组")
@@ -287,7 +310,9 @@ class FishFinRechargeClient:
out_order_id = str(out_order_id).strip() out_order_id = str(out_order_id).strip()
if not out_order_id: if not out_order_id:
raise ValueError("out_order_id 不能为空") raise ValueError("out_order_id 不能为空")
return self._request("GET", self.QUERY_ORDER_PATH, {"out_order_id": out_order_id}) return self._request(
"GET", self.QUERY_ORDER_PATH, {"out_order_id": out_order_id}
)
def account_info(self) -> dict[str, Any]: def account_info(self) -> dict[str, Any]:
"""查询商户账户信息。""" """查询商户账户信息。"""
+193 -52
View File
@@ -4,46 +4,78 @@ from typing import Any
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5, AES from Crypto.Cipher import PKCS1_v1_5, AES
# 随机产生4个字符组成的字符串 # 随机产生4个字符组成的字符串
def four_random_chart() -> str: def four_random_chart() -> str:
return hex(int(65536 * (1 + random.random())))[2:][1:] return hex(int(65536 * (1 + random.random())))[2:][1:]
# PKCS#1 v1.5 填充 + RSA 加密 # PKCS#1 v1.5 填充 + RSA 加密
def parse_jsbn_bigint(n_obj:dict[Any, int]) -> int: def parse_jsbn_bigint(n_obj: dict[Any, int]) -> int:
DB = 28 DB = 28
DV = 1 << DB DV = 1 << DB
t = n_obj['t'] t = n_obj["t"]
result = 0 result = 0
for i in range(t): for i in range(t):
result += n_obj[i] * (DV ** i) result += n_obj[i] * (DV**i)
return result return result
def encrypt_data(plaintext:str) -> str:
def encrypt_data(plaintext: str) -> str:
cipher = PKCS1_v1_5.new(public_key) cipher = PKCS1_v1_5.new(public_key)
encrypted = cipher.encrypt(plaintext.encode('utf-8')) encrypted = cipher.encrypt(plaintext.encode("utf-8"))
# 转换为十六进制,确保偶数长度 # 转换为十六进制,确保偶数长度
hex_result = encrypted.hex() hex_result = encrypted.hex()
if len(hex_result) % 2 == 1: if len(hex_result) % 2 == 1:
hex_result = '0' + hex_result hex_result = "0" + hex_result
return hex_result return hex_result
def RSA_jiami_r(str_16:str) -> str:
def RSA_jiami_r(str_16: str) -> str:
global public_key global public_key
# 你的数据 # 你的数据
n_data = { n_data = {
0: 134982529, 1: 254232810, 2: 164556709, 3: 234907349, 0: 134982529,
4: 134685994, 5: 35463984, 6: 258277946, 7: 12518857, 1: 254232810,
8: 44638621, 9: 93783641, 10: 212253739, 11: 62792472, 2: 164556709,
12: 186688352, 13: 109500232, 14: 182488077, 15: 261196188, 3: 234907349,
16: 26354094, 17: 103248217, 18: 106891695, 19: 165771045, 4: 134685994,
20: 41530993, 21: 263704736, 22: 111785174, 23: 12753611, 5: 35463984,
24: 232116673, 25: 155524985, 26: 218291229, 27: 122452343, 6: 258277946,
28: 248250238, 29: 118739550, 30: 251169095, 31: 129059733, 7: 12518857,
32: 149835464, 33: 5498868, 34: 71719731, 35: 154456417, 8: 44638621,
9: 93783641,
10: 212253739,
11: 62792472,
12: 186688352,
13: 109500232,
14: 182488077,
15: 261196188,
16: 26354094,
17: 103248217,
18: 106891695,
19: 165771045,
20: 41530993,
21: 263704736,
22: 111785174,
23: 12753611,
24: 232116673,
25: 155524985,
26: 218291229,
27: 122452343,
28: 248250238,
29: 118739550,
30: 251169095,
31: 129059733,
32: 149835464,
33: 5498868,
34: 71719731,
35: 154456417,
36: 49635, 36: 49635,
't': 37, 's': 0 "t": 37,
"s": 0,
} }
e = 65537 e = 65537
@@ -54,11 +86,12 @@ def RSA_jiami_r(str_16:str) -> str:
encrypted = encrypt_data(str_16) encrypted = encrypt_data(str_16)
return encrypted return encrypted
# AES加密 # AES加密
# 加密模式: AES-CBC # 加密模式: AES-CBC
# 密钥长度: 128位 # 密钥长度: 128位
# IV: 固定为 "0000000000000000" # IV: 固定为 "0000000000000000"
def parse_string_to_wordarray(text:str) -> list[int]: def parse_string_to_wordarray(text: str) -> list[int]:
"""将字符串转换为 WordArray 格式""" """将字符串转换为 WordArray 格式"""
length = len(text) length = len(text)
words = [] words = []
@@ -81,13 +114,15 @@ def parse_string_to_wordarray(text:str) -> list[int]:
words[word_index] |= char_code << shift words[word_index] |= char_code << shift
return words return words
def AES_O(plaintext:str, str_16:str) -> list[int]:
def AES_O(plaintext: str, str_16: str) -> list[int]:
# 密钥 # 密钥
key_words = parse_string_to_wordarray(str_16) key_words = parse_string_to_wordarray(str_16)
key = b''.join(w.to_bytes(4, 'big') for w in key_words) key = b"".join(w.to_bytes(4, "big") for w in key_words)
# IV # IV
iv = b'0000' * 4 # "0000000000000000" iv = b"0000" * 4 # "0000000000000000"
# 填充(PKCS7) # 填充(PKCS7)
pad_len = 16 - len(plaintext) % 16 pad_len = 16 - len(plaintext) % 16
@@ -100,13 +135,14 @@ def AES_O(plaintext:str, str_16:str) -> list[int]:
# 结果是字节数组 # 结果是字节数组
return list(ciphertext) return list(ciphertext)
# 自定义base64编码 # 自定义base64编码
def geetest_base64_encode(data:list[int]) -> dict[str, Any]: def geetest_base64_encode(data: list[int]) -> dict[str, Any]:
"""极验自定义Base64编码""" """极验自定义Base64编码"""
# 配置 # 配置
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789()' charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789()"
pad_char = '.' pad_char = "."
# 位掩码 (这些是打乱的) # 位掩码 (这些是打乱的)
masks = [7274496, 9483264, 19220, 235] masks = [7274496, 9483264, 19220, 235]
@@ -159,13 +195,10 @@ def geetest_base64_encode(data:list[int]) -> dict[str, Any]:
break break
return { return {"res": encoded, "end": padding}
"res": encoded,
"end": padding
}
def encrypt_string(e:str, t:list[int], n:str) -> str: def encrypt_string(e: str, t: list[int], n: str) -> str:
""" """
JS加密函数的Python实现 JS加密函数的Python实现
@@ -185,7 +218,7 @@ def encrypt_string(e:str, t:list[int], n:str) -> str:
# 每次读取2个字符(十六进制) # 每次读取2个字符(十六进制)
while o < len(n): while o < len(n):
r = n[o:o + 2] # 取2个字符 r = n[o : o + 2] # 取2个字符
if len(r) < 2: if len(r) < 2:
break break
o += 2 o += 2
@@ -205,7 +238,7 @@ def encrypt_string(e:str, t:list[int], n:str) -> str:
return i return i
def simple_md5(message:str) -> str: def simple_md5(message: str) -> str:
""" """
简化版MD5实现,结构更清晰 简化版MD5实现,结构更清晰
""" """
@@ -216,31 +249,139 @@ def simple_md5(message:str) -> str:
# 轮移位常量 # 轮移位常量
shifts = [ shifts = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7,
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 12,
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 17,
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21 22,
7,
12,
17,
22,
7,
12,
17,
22,
7,
12,
17,
22,
5,
9,
14,
20,
5,
9,
14,
20,
5,
9,
14,
20,
5,
9,
14,
20,
4,
11,
16,
23,
4,
11,
16,
23,
4,
11,
16,
23,
4,
11,
16,
23,
6,
10,
15,
21,
6,
10,
15,
21,
6,
10,
15,
21,
6,
10,
15,
21,
] ]
# K常数(与JavaScript版本中的常数对应) # K常数(与JavaScript版本中的常数对应)
K = [ K = [
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xD76AA478,
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, 0xE8C7B756,
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x242070DB,
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, 0xC1BDCEEE,
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xF57C0FAF,
0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x4787C62A,
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xA8304613,
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xFD469501,
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0x698098D8,
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x8B44F7AF,
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xFFFF5BB1,
0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0x895CD7BE,
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x6B901122,
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, 0xFD987193,
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xA679438E,
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391 0x49B40821,
0xF61E2562,
0xC040B340,
0x265E5A51,
0xE9B6C7AA,
0xD62F105D,
0x02441453,
0xD8A1E681,
0xE7D3FBC8,
0x21E1CDE6,
0xC33707D6,
0xF4D50D87,
0x455A14ED,
0xA9E3E905,
0xFCEFA3F8,
0x676F02D9,
0x8D2A4C8A,
0xFFFA3942,
0x8771F681,
0x6D9D6122,
0xFDE5380C,
0xA4BEEA44,
0x4BDECFA9,
0xF6BB4B60,
0xBEBFBC70,
0x289B7EC6,
0xEAA127FA,
0xD4EF3085,
0x04881D05,
0xD9D4D039,
0xE6DB99E5,
0x1FA27CF8,
0xC4AC5665,
0xF4292244,
0x432AFF97,
0xAB9423A7,
0xFC93A039,
0x655B59C3,
0x8F0CCC92,
0xFFEFF47D,
0x85845DD1,
0x6FA87E4F,
0xFE2CE6E0,
0xA3014314,
0x4E0811A1,
0xF7537E82,
0xBD3AF235,
0x2AD7D2BB,
0xEB86D391,
] ]
# 实际实现... # 实际实现...
return verify_result(message) return verify_result(message)
+60 -8
View File
@@ -11,6 +11,7 @@ REQUEST_TIMEOUT = (3.05, 12)
def pilImgToCv2(img: Image.Image, flag=cv2.COLOR_RGB2BGR): def pilImgToCv2(img: Image.Image, flag=cv2.COLOR_RGB2BGR):
return cv2.cvtColor(np.asarray(img), flag) return cv2.cvtColor(np.asarray(img), flag)
# 识别图片缺口返回滑块距离 # 识别图片缺口返回滑块距离
def shibie(img: Image.Image, slice: Image.Image): def shibie(img: Image.Image, slice: Image.Image):
# 通过 pilImgToCv2 将图片置灰 # 通过 pilImgToCv2 将图片置灰
@@ -38,20 +39,70 @@ def shibie(img: Image.Image, slice: Image.Image):
# showImg(resultBg) # 可以通过它来看处理后的图片效果 # showImg(resultBg) # 可以通过它来看处理后的图片效果
return distance return distance
# 还原图片 # 还原图片
def restore_geetest_image(input_path:str, output_path:str) -> None: def restore_geetest_image(input_path: str, output_path: str) -> None:
""" """
还原极验打乱的验证码图像 还原极验打乱的验证码图像
""" """
Ut = [ Ut = [
39, 38, 48, 49, 41, 40, 46, 47, 35, 34, 50, 51, 33, 32, 28, 29, 39,
27, 26, 36, 37, 31, 30, 44, 45, 43, 42, 12, 13, 23, 22, 14, 15, 38,
21, 20, 8, 9, 25, 24, 6, 7, 3, 2, 0, 1, 11, 10, 4, 5, 19, 18, 16, 17 48,
49,
41,
40,
46,
47,
35,
34,
50,
51,
33,
32,
28,
29,
27,
26,
36,
37,
31,
30,
44,
45,
43,
42,
12,
13,
23,
22,
14,
15,
21,
20,
8,
9,
25,
24,
6,
7,
3,
2,
0,
1,
11,
10,
4,
5,
19,
18,
16,
17,
] ]
# 打开混淆图像 # 打开混淆图像
img = Image.open(input_path) img = Image.open(input_path)
new_img = Image.new("RGB", (260,160)) new_img = Image.new("RGB", (260, 160))
r = 160 r = 160
for _ in range(len(Ut)): for _ in range(len(Ut)):
a = r / 2 a = r / 2
@@ -63,11 +114,12 @@ def restore_geetest_image(input_path:str, output_path:str) -> None:
new_img.save(output_path) new_img.save(output_path)
logger.debug("图像已还原并保存到: {}", output_path) logger.debug("图像已还原并保存到: {}", output_path)
# 下载图片 # 下载图片
def download_picture(bg:str, fullbg:str, slice:str) -> int: def download_picture(bg: str, fullbg: str, slice: str) -> int:
for i in range(3): for i in range(3):
if i == 0: if i == 0:
url = "https://static.geetest.com/"+bg url = "https://static.geetest.com/" + bg
response = requests.get(url, timeout=REQUEST_TIMEOUT) response = requests.get(url, timeout=REQUEST_TIMEOUT)
response.raise_for_status() response.raise_for_status()
with open("bg.jpg", "wb") as f: with open("bg.jpg", "wb") as f:
@@ -87,4 +139,4 @@ def download_picture(bg:str, fullbg:str, slice:str) -> int:
response.raise_for_status() response.raise_for_status()
with open("slice.jpg", "wb") as f: with open("slice.jpg", "wb") as f:
f.write(response.content) f.write(response.content)
return shibie(Image.open('fullbg.jpg'), Image.open('slice.jpg')) return shibie(Image.open("fullbg.jpg"), Image.open("slice.jpg"))
+173 -143
View File
@@ -29,13 +29,15 @@ def _get(
def _parse_jsonp_response(response: requests.Response, source: str) -> dict: def _parse_jsonp_response(response: requests.Response, source: str) -> dict:
"""解析极验 JSONP 响应。""" """解析极验 JSONP 响应。"""
response.raise_for_status() response.raise_for_status()
match = re.search(r'\((.*)\)$', response.text) match = re.search(r"\((.*)\)$", response.text)
if not match: if not match:
raise ValueError(f"{source} 无法解析 JSONP 响应") raise ValueError(f"{source} 无法解析 JSONP 响应")
data = json.loads(match.group(1)) data = json.loads(match.group(1))
if data.get('status') == 'error': if data.get("status") == "error":
raise ValueError(f"{source} 失败: {data.get('user_error', data.get('error', '未知错误'))}") raise ValueError(
f"{source} 失败: {data.get('user_error', data.get('error', '未知错误'))}"
)
return data return data
@@ -55,26 +57,26 @@ def _parse_json_response(response: requests.Response, source: str) -> dict:
def get_challenge_gt_bak() -> Tuple[str, str]: def get_challenge_gt_bak() -> Tuple[str, str]:
headers = { headers = {
'accept': 'application/json, text/javascript, */*; q=0.01', "accept": "application/json, text/javascript, */*; q=0.01",
'accept-language': 'zh-CN,zh;q=0.9', "accept-language": "zh-CN,zh;q=0.9",
'priority': 'u=1, i', "priority": "u=1, i",
'referer': 'https://demos.geetest.com/slide-float.html', "referer": "https://demos.geetest.com/slide-float.html",
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', "sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
'sec-ch-ua-mobile': '?0', "sec-ch-ua-mobile": "?0",
'sec-ch-ua-platform': '"Windows"', "sec-ch-ua-platform": '"Windows"',
'sec-fetch-dest': 'empty', "sec-fetch-dest": "empty",
'sec-fetch-mode': 'cors', "sec-fetch-mode": "cors",
'sec-fetch-site': 'same-origin', "sec-fetch-site": "same-origin",
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
'x-requested-with': 'XMLHttpRequest', "x-requested-with": "XMLHttpRequest",
} }
params = { params = {
't': str(int(round(time.time() * 1000))), "t": str(int(round(time.time() * 1000))),
} }
response = requests.get( response = requests.get(
'https://demos.geetest.com/gt/register-slide', "https://demos.geetest.com/gt/register-slide",
params=params, params=params,
headers=headers, headers=headers,
timeout=10, timeout=10,
@@ -85,21 +87,21 @@ def get_challenge_gt_bak() -> Tuple[str, str]:
def get_challenge_gt() -> Tuple[str, str]: def get_challenge_gt() -> Tuple[str, str]:
headers = { headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36', "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
'Accept': 'application/json, text/javascript, */*; q=0.01', "Accept": "application/json, text/javascript, */*; q=0.01",
'Content-Type': 'application/x-www-form-urlencoded', "Content-Type": "application/x-www-form-urlencoded",
'referer': 'https://passport.douyu.com/member/login?state=https%3A%2F%2Fwww.douyu.com%2Fmember%2FcpSecurity%2Fcheck_geetest_status', "referer": "https://passport.douyu.com/member/login?state=https%3A%2F%2Fwww.douyu.com%2Fmember%2FcpSecurity%2Fcheck_geetest_status",
} }
data = { data = {
'type': '1', "type": "1",
'nicknameOrPhoneEncrypt': '03ILaBwtmmCm0A==', "nicknameOrPhoneEncrypt": "03ILaBwtmmCm0A==",
'password': '57219dddec71c31b7647683fa5306103', "password": "57219dddec71c31b7647683fa5306103",
'biz_type': '1', "biz_type": "1",
} }
response = requests.post( response = requests.post(
'https://passport.douyu.com/wgapi/member/passport/login', "https://passport.douyu.com/wgapi/member/passport/login",
headers=headers, headers=headers,
data=data, data=data,
timeout=10, timeout=10,
@@ -114,28 +116,27 @@ def get_challenge_gt() -> Tuple[str, str]:
raise ValueError(f"斗鱼登录接口返回中缺少极验参数: {preview}") from exc raise ValueError(f"斗鱼登录接口返回中缺少极验参数: {preview}") from exc
def get_js_address(gt: str, proxies: Optional[Mapping[str, str]] = None) -> dict: def get_js_address(gt: str, proxies: Optional[Mapping[str, str]] = None) -> dict:
headers = { headers = {
'accept': '*/*', "accept": "*/*",
'accept-language': 'zh-CN,zh;q=0.9', "accept-language": "zh-CN,zh;q=0.9",
'referer': PASSPORT_REFERER, "referer": PASSPORT_REFERER,
'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', "sec-ch-ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
'sec-ch-ua-mobile': '?0', "sec-ch-ua-mobile": "?0",
'sec-ch-ua-platform': '"macOS"', "sec-ch-ua-platform": '"macOS"',
'sec-fetch-dest': 'script', "sec-fetch-dest": "script",
'sec-fetch-mode': 'no-cors', "sec-fetch-mode": "no-cors",
'sec-fetch-site': 'cross-site', "sec-fetch-site": "cross-site",
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36', "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
} }
params = { params = {
'gt': gt, "gt": gt,
'callback': 'geetest_' + str(int(round(time.time() * 1000))), "callback": "geetest_" + str(int(round(time.time() * 1000))),
} }
response = _get( response = _get(
'https://api.geetest.com/gettype.php', "https://api.geetest.com/gettype.php",
params=params, params=params,
headers=headers, headers=headers,
proxies=proxies, proxies=proxies,
@@ -143,6 +144,7 @@ def get_js_address(gt: str, proxies: Optional[Mapping[str, str]] = None) -> dict
return _parse_jsonp_response(response, "极验 gettype") return _parse_jsonp_response(response, "极验 gettype")
def get_c_s( def get_c_s(
gt: str, gt: str,
challenge: str, challenge: str,
@@ -150,26 +152,32 @@ def get_c_s(
proxies: Optional[Mapping[str, str]] = None, proxies: Optional[Mapping[str, str]] = None,
) -> Tuple[list[int], str]: ) -> Tuple[list[int], str]:
headers = { headers = {
'accept': '*/*', "accept": "*/*",
'accept-language': 'zh-CN,zh;q=0.9', "accept-language": "zh-CN,zh;q=0.9",
'referer': PASSPORT_REFERER, "referer": PASSPORT_REFERER,
'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', "sec-ch-ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
'sec-ch-ua-mobile': '?0', "sec-ch-ua-mobile": "?0",
'sec-ch-ua-platform': '"macOS"', "sec-ch-ua-platform": '"macOS"',
'sec-fetch-dest': 'script', "sec-fetch-dest": "script",
'sec-fetch-mode': 'no-cors', "sec-fetch-mode": "no-cors",
'sec-fetch-site': 'cross-site', "sec-fetch-site": "cross-site",
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36', "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
} }
response = _get( response = _get(
'https://api.geetest.com/get.php?gt=' + gt + '&challenge=' + challenge + '&lang=zh-cn&pt=0&client_type=web&w=' + w + '&callback=geetest_' + str( "https://api.geetest.com/get.php?gt="
int(round(time.time() * 1000))), + gt
+ "&challenge="
+ challenge
+ "&lang=zh-cn&pt=0&client_type=web&w="
+ w
+ "&callback=geetest_"
+ str(int(round(time.time() * 1000))),
headers=headers, headers=headers,
proxies=proxies, proxies=proxies,
) )
data = _parse_jsonp_response(response, "极验 get.php") data = _parse_jsonp_response(response, "极验 get.php")
return data['data']['c'], data['data']['s'] return data["data"]["c"], data["data"]["s"]
def req_fullpage_validate( def req_fullpage_validate(
@@ -180,159 +188,181 @@ def req_fullpage_validate(
) -> dict: ) -> dict:
"""HAR 中的 fullpage 最终校验,成功后直接返回 validate。""" """HAR 中的 fullpage 最终校验,成功后直接返回 validate。"""
headers = { headers = {
'Accept': '*/*', "Accept": "*/*",
'Accept-Language': 'zh-CN,zh;q=0.9', "Accept-Language": "zh-CN,zh;q=0.9",
'Connection': 'keep-alive', "Connection": "keep-alive",
'Referer': PASSPORT_REFERER, "Referer": PASSPORT_REFERER,
'Sec-Fetch-Dest': 'script', "Sec-Fetch-Dest": "script",
'Sec-Fetch-Mode': 'no-cors', "Sec-Fetch-Mode": "no-cors",
'Sec-Fetch-Site': 'cross-site', "Sec-Fetch-Site": "cross-site",
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36', "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', "sec-ch-ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
'sec-ch-ua-mobile': '?0', "sec-ch-ua-mobile": "?0",
'sec-ch-ua-platform': '"macOS"', "sec-ch-ua-platform": '"macOS"',
} }
response = _get( response = _get(
'https://api.geetest.com/ajax.php?gt=' + gt + '&challenge=' + challenge + '&lang=zh-cn&pt=0&client_type=web&w=' + w + '&callback=geetest_' + str( "https://api.geetest.com/ajax.php?gt="
int(round(time.time() * 1000))), + gt
+ "&challenge="
+ challenge
+ "&lang=zh-cn&pt=0&client_type=web&w="
+ w
+ "&callback=geetest_"
+ str(int(round(time.time() * 1000))),
headers=headers, headers=headers,
proxies=proxies, proxies=proxies,
) )
return _parse_jsonp_response(response, "极验 fullpage ajax.php") return _parse_jsonp_response(response, "极验 fullpage ajax.php")
def req_slide(gt:str, challenge:str, w2:str) -> None:
def req_slide(gt: str, challenge: str, w2: str) -> None:
headers = { headers = {
'Accept': '*/*', "Accept": "*/*",
'Accept-Language': 'zh-CN,zh;q=0.9', "Accept-Language": "zh-CN,zh;q=0.9",
'Connection': 'keep-alive', "Connection": "keep-alive",
'Referer': 'https://demos.geetest.com/', "Referer": "https://demos.geetest.com/",
'Sec-Fetch-Dest': 'script', "Sec-Fetch-Dest": "script",
'Sec-Fetch-Mode': 'no-cors', "Sec-Fetch-Mode": "no-cors",
'Sec-Fetch-Site': 'cross-site', "Sec-Fetch-Site": "cross-site",
'Sec-Fetch-Storage-Access': 'active', "Sec-Fetch-Storage-Access": "active",
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', "sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
'sec-ch-ua-mobile': '?0', "sec-ch-ua-mobile": "?0",
'sec-ch-ua-platform': '"Windows"', "sec-ch-ua-platform": '"Windows"',
} }
response = requests.get( response = requests.get(
'https://api.geevisit.com/ajax.php?gt=' + gt + '&challenge=' + challenge + '&lang=zh-cn&pt=0&client_type=web&w=' + w2 + '&callback=geetest_' + str( "https://api.geevisit.com/ajax.php?gt="
int(round(time.time() * 1000))), + gt
+ "&challenge="
+ challenge
+ "&lang=zh-cn&pt=0&client_type=web&w="
+ w2
+ "&callback=geetest_"
+ str(int(round(time.time() * 1000))),
headers=headers, headers=headers,
timeout=REQUEST_TIMEOUT, timeout=REQUEST_TIMEOUT,
) )
response.raise_for_status() response.raise_for_status()
def get_picture(gt:str, challenge:str) -> tuple[str, str, list[int], str, str, str]:
def get_picture(gt: str, challenge: str) -> tuple[str, str, list[int], str, str, str]:
headers = { headers = {
'Accept': '*/*', "Accept": "*/*",
'Accept-Language': 'zh-CN,zh;q=0.9', "Accept-Language": "zh-CN,zh;q=0.9",
'Connection': 'keep-alive', "Connection": "keep-alive",
'Referer': 'https://demos.geetest.com/', "Referer": "https://demos.geetest.com/",
'Sec-Fetch-Dest': 'script', "Sec-Fetch-Dest": "script",
'Sec-Fetch-Mode': 'no-cors', "Sec-Fetch-Mode": "no-cors",
'Sec-Fetch-Site': 'cross-site', "Sec-Fetch-Site": "cross-site",
'Sec-Fetch-Storage-Access': 'active', "Sec-Fetch-Storage-Access": "active",
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', "sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
'sec-ch-ua-mobile': '?0', "sec-ch-ua-mobile": "?0",
'sec-ch-ua-platform': '"Windows"', "sec-ch-ua-platform": '"Windows"',
} }
params = { params = {
'is_next': 'true', "is_next": "true",
'type': 'slide3', "type": "slide3",
'gt': gt, "gt": gt,
'challenge': challenge, "challenge": challenge,
'lang': 'zh-cn', "lang": "zh-cn",
'https': 'true', "https": "true",
'protocol': 'https://', "protocol": "https://",
'offline': 'false', "offline": "false",
'product': 'embed', "product": "embed",
'api_server': 'api.geevisit.com', "api_server": "api.geevisit.com",
'isPC': 'true', "isPC": "true",
'autoReset': 'true', "autoReset": "true",
'width': '100%', "width": "100%",
'callback': 'geetest_'+str(int(round(time.time() * 1000))), "callback": "geetest_" + str(int(round(time.time() * 1000))),
} }
response = requests.get( response = requests.get(
'https://api.geevisit.com/get.php', "https://api.geevisit.com/get.php",
params=params, params=params,
headers=headers, headers=headers,
timeout=REQUEST_TIMEOUT, timeout=REQUEST_TIMEOUT,
) )
response.raise_for_status() response.raise_for_status()
logger.debug("极验 get.php 原始响应: {}", response.text[:500]) logger.debug("极验 get.php 原始响应: {}", response.text[:500])
match = re.search(r'\((.*)\)$', response.text) match = re.search(r"\((.*)\)$", response.text)
if match: if match:
json_str = match.group(1) json_str = match.group(1)
data = json.loads(json_str) data = json.loads(json_str)
if 'data' in data and isinstance(data['data'], dict): if "data" in data and isinstance(data["data"], dict):
# 新版极验格式 # 新版极验格式
inner_data = data['data'] inner_data = data["data"]
return ( return (
inner_data.get('bg', ''), inner_data.get("bg", ""),
inner_data.get('fullbg', ''), inner_data.get("fullbg", ""),
inner_data.get('c', []), inner_data.get("c", []),
inner_data.get('s', ''), inner_data.get("s", ""),
inner_data.get('slice', ''), inner_data.get("slice", ""),
inner_data.get('challenge', '') inner_data.get("challenge", ""),
) )
else: else:
# 旧版极验格式 # 旧版极验格式
return ( return (
data.get('bg', ''), data.get("bg", ""),
data.get('fullbg', ''), data.get("fullbg", ""),
data.get('c', []), data.get("c", []),
data.get('s', ''), data.get("s", ""),
data.get('slice', ''), data.get("slice", ""),
data.get('challenge', '') data.get("challenge", ""),
) )
else: else:
raise ValueError("无法解析 JSONP 响应") raise ValueError("无法解析 JSONP 响应")
def req_end(gt:str, challenge:str, w:str) -> dict:
def req_end(gt: str, challenge: str, w: str) -> dict:
headers = { headers = {
'Accept': '*/*', "Accept": "*/*",
'Accept-Language': 'zh-CN,zh;q=0.9', "Accept-Language": "zh-CN,zh;q=0.9",
'Connection': 'keep-alive', "Connection": "keep-alive",
'Referer': 'https://demos.geetest.com/', "Referer": "https://demos.geetest.com/",
'Sec-Fetch-Dest': 'script', "Sec-Fetch-Dest": "script",
'Sec-Fetch-Mode': 'no-cors', "Sec-Fetch-Mode": "no-cors",
'Sec-Fetch-Site': 'cross-site', "Sec-Fetch-Site": "cross-site",
'Sec-Fetch-Storage-Access': 'active', "Sec-Fetch-Storage-Access": "active",
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', "sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
'sec-ch-ua-mobile': '?0', "sec-ch-ua-mobile": "?0",
'sec-ch-ua-platform': '"Windows"', "sec-ch-ua-platform": '"Windows"',
} }
response = requests.get( response = requests.get(
'https://api.geevisit.com/ajax.php?gt='+gt+'&challenge='+challenge+'&lang=zh-cn&%24_BCm=0&client_type=web&w='+w+'&callback=geetest_'+str(int(round(time.time() * 1000))), "https://api.geevisit.com/ajax.php?gt="
+ gt
+ "&challenge="
+ challenge
+ "&lang=zh-cn&%24_BCm=0&client_type=web&w="
+ w
+ "&callback=geetest_"
+ str(int(round(time.time() * 1000))),
headers=headers, headers=headers,
timeout=REQUEST_TIMEOUT, timeout=REQUEST_TIMEOUT,
) )
response.raise_for_status() response.raise_for_status()
logger.debug("极验 ajax.php 原始响应: {}", response.text[:500]) logger.debug("极验 ajax.php 原始响应: {}", response.text[:500])
match = re.search(r'\((.*)\)$', response.text) match = re.search(r"\((.*)\)$", response.text)
if match: if match:
json_str = match.group(1) json_str = match.group(1)
data = json.loads(json_str) data = json.loads(json_str)
logger.debug("极验验证响应: {}", data) logger.debug("极验验证响应: {}", data)
# 检查验证是否成功 # 检查验证是否成功
if data.get('success') == 1: if data.get("success") == 1:
return data return data
else: else:
# 如果验证失败,返回错误信息 # 如果验证失败,返回错误信息
return { return {
'success': 0, "success": 0,
'message': data.get('message', '验证失败'), "message": data.get("message", "验证失败"),
'validate': '' "validate": "",
} }
else: else:
raise ValueError("无法解析 JSONP 响应") raise ValueError("无法解析 JSONP 响应")
+49 -40
View File
@@ -18,63 +18,70 @@ def generate_fake_performance_timing(base_time: Optional[int] = None) -> dict[st
# 定义合理的时间间隔范围(毫秒) # 定义合理的时间间隔范围(毫秒)
intervals = { intervals = {
'fetch': random.randint(1, 2), "fetch": random.randint(1, 2),
'domain_lookup_start': random.randint(3, 5), "domain_lookup_start": random.randint(3, 5),
'domain_lookup': random.randint(5, 15), "domain_lookup": random.randint(5, 15),
'connect': random.randint(50, 150), "connect": random.randint(50, 150),
'ssl_offset': random.randint(30, 50), "ssl_offset": random.randint(30, 50),
'request': random.randint(1, 5), "request": random.randint(1, 5),
'response': random.randint(20, 100), "response": random.randint(20, 100),
'response_end': random.randint(1, 3), "response_end": random.randint(1, 3),
'unload_start': random.randint(1, 3), "unload_start": random.randint(1, 3),
'unload': random.randint(1, 5), "unload": random.randint(1, 5),
'dom_loading': random.randint(1, 3), "dom_loading": random.randint(1, 3),
'dom_interactive': random.randint(50, 200), "dom_interactive": random.randint(50, 200),
'dom_content_loaded': random.randint(1, 3), "dom_content_loaded": random.randint(1, 3),
'load_event': random.randint(0, 5) "load_event": random.randint(0, 5),
} }
timing = {} timing = {}
# 按照时间顺序构建 # 按照时间顺序构建
timing['navigationStart'] = base_time timing["navigationStart"] = base_time
timing['fetchStart'] = timing['navigationStart'] + intervals['fetch'] timing["fetchStart"] = timing["navigationStart"] + intervals["fetch"]
timing['domainLookupStart'] = timing['fetchStart'] + intervals['domain_lookup_start'] timing["domainLookupStart"] = (
timing['domainLookupEnd'] = timing['domainLookupStart'] + intervals['domain_lookup'] timing["fetchStart"] + intervals["domain_lookup_start"]
)
timing["domainLookupEnd"] = timing["domainLookupStart"] + intervals["domain_lookup"]
timing['connectStart'] = timing['domainLookupEnd'] timing["connectStart"] = timing["domainLookupEnd"]
timing['secureConnectionStart'] = timing['connectStart'] + intervals['ssl_offset'] timing["secureConnectionStart"] = timing["connectStart"] + intervals["ssl_offset"]
timing['connectEnd'] = timing['connectStart'] + intervals['connect'] timing["connectEnd"] = timing["connectStart"] + intervals["connect"]
timing['requestStart'] = timing['connectEnd'] + intervals['request'] timing["requestStart"] = timing["connectEnd"] + intervals["request"]
timing['responseStart'] = timing['requestStart'] + intervals['response'] timing["responseStart"] = timing["requestStart"] + intervals["response"]
timing['responseEnd'] = timing['responseStart'] + intervals['response_end'] timing["responseEnd"] = timing["responseStart"] + intervals["response_end"]
timing['unloadEventStart'] = timing['responseEnd'] + intervals['unload_start'] timing["unloadEventStart"] = timing["responseEnd"] + intervals["unload_start"]
timing['unloadEventEnd'] = timing['unloadEventStart'] + intervals['unload'] timing["unloadEventEnd"] = timing["unloadEventStart"] + intervals["unload"]
timing['domLoading'] = timing['unloadEventEnd'] + intervals['dom_loading'] timing["domLoading"] = timing["unloadEventEnd"] + intervals["dom_loading"]
timing['domInteractive'] = timing['domLoading'] + intervals['dom_interactive'] timing["domInteractive"] = timing["domLoading"] + intervals["dom_interactive"]
timing['domContentLoadedEventStart'] = timing['domInteractive'] timing["domContentLoadedEventStart"] = timing["domInteractive"]
timing['domContentLoadedEventEnd'] = timing['domInteractive'] + intervals['dom_content_loaded'] timing["domContentLoadedEventEnd"] = (
timing['domComplete'] = timing['domContentLoadedEventEnd'] timing["domInteractive"] + intervals["dom_content_loaded"]
timing['loadEventStart'] = timing['domComplete'] )
timing['loadEventEnd'] = timing['loadEventStart'] + intervals['load_event'] timing["domComplete"] = timing["domContentLoadedEventEnd"]
timing["loadEventStart"] = timing["domComplete"]
timing["loadEventEnd"] = timing["loadEventStart"] + intervals["load_event"]
# 无重定向的情况 # 无重定向的情况
timing['redirectStart'] = 0 timing["redirectStart"] = 0
timing['redirectEnd'] = 0 timing["redirectEnd"] = 0
return timing return timing
def __ease_out_expo(sep): def __ease_out_expo(sep):
''' """
轨迹相关操作 轨迹相关操作
''' """
if sep == 1: if sep == 1:
return 1 return 1
else: else:
return 1 - pow(2, -10 * sep) return 1 - pow(2, -10 * sep)
def get_slide_track(distance): def get_slide_track(distance):
""" """
根据滑动距离生成滑动轨迹 根据滑动距离生成滑动轨迹
@@ -86,7 +93,9 @@ def get_slide_track(distance):
""" """
if not isinstance(distance, int) or distance < 0: if not isinstance(distance, int) or distance < 0:
raise ValueError(f"distance类型必须是大于等于0的整数: distance: {distance}, type: {type(distance)}") raise ValueError(
f"distance类型必须是大于等于0的整数: distance: {distance}, type: {type(distance)}"
)
# 初始化轨迹列表 # 初始化轨迹列表
slide_track = [ slide_track = [
[random.randint(-50, -10), random.randint(-50, -10), 0], [random.randint(-50, -10), random.randint(-50, -10), 0],
@@ -110,4 +119,4 @@ def get_slide_track(distance):
slide_track.append([x, _y, t]) slide_track.append([x, _y, t])
_x = x _x = x
slide_track.append(slide_track[-1]) slide_track.append(slide_track[-1])
return slide_track, slide_track[-1][2] return slide_track, slide_track[-1][2]
+85 -25
View File
@@ -2,25 +2,60 @@ import time
import random import random
import json import json
from loguru import logger from loguru import logger
from core.geetest.common.trajectory import generate_realistic_trajectory, process_mouse_trajectory, compress_trajectory, TrajectoryEncoder, \ from core.geetest.common.trajectory import (
H generate_realistic_trajectory,
from core.geetest.common.crypto import four_random_chart, RSA_jiami_r, AES_O, geetest_base64_encode, encrypt_string, simple_md5 process_mouse_trajectory,
compress_trajectory,
TrajectoryEncoder,
H,
)
from core.geetest.common.crypto import (
four_random_chart,
RSA_jiami_r,
AES_O,
geetest_base64_encode,
encrypt_string,
simple_md5,
)
from core.geetest.common.imaging import download_picture from core.geetest.common.imaging import download_picture
from core.geetest.common.network import get_challenge_gt, get_js_address, get_c_s, req_slide, get_picture, req_end from core.geetest.common.network import (
from core.geetest.common.performance import generate_fake_performance_timing, get_slide_track get_challenge_gt,
get_js_address,
get_c_s,
req_slide,
get_picture,
req_end,
)
from core.geetest.common.performance import (
generate_fake_performance_timing,
get_slide_track,
)
def _generate_seed() -> str: def _generate_seed() -> str:
return four_random_chart() + four_random_chart() + four_random_chart() + four_random_chart() return (
four_random_chart()
+ four_random_chart()
+ four_random_chart()
+ four_random_chart()
)
def get_w1(gt:str, challenge:str, str_16:str) -> str:
def get_w1(gt: str, challenge: str, str_16: str) -> str:
r = RSA_jiami_r(str_16) r = RSA_jiami_r(str_16)
plaintext = '{"gt":"' + gt + '","challenge":"' + challenge + '","offline":false,"new_captcha":true,"product":"float","width":"300px","https":true,"api_server":"apiv6.geetest.com","protocol":"https://","type":"fullpage","static_servers":["static.geetest.com/","static.geevisit.com/"],"voice":"/static/js/voice.1.2.6.js","click":"/static/js/click.3.1.2.js","beeline":"/static/js/beeline.1.0.1.js","fullpage":"/static/js/fullpage.9.2.0-guwyxh.js","slide":"/static/js/slide.7.9.3.js","geetest":"/static/js/geetest.6.0.9.js","aspect_radio":{"slide":103,"click":128,"voice":128,"beeline":50},"cc":16,"ww":true,"i":"-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1"}' plaintext = (
'{"gt":"'
+ gt
+ '","challenge":"'
+ challenge
+ '","offline":false,"new_captcha":true,"product":"float","width":"300px","https":true,"api_server":"apiv6.geetest.com","protocol":"https://","type":"fullpage","static_servers":["static.geetest.com/","static.geevisit.com/"],"voice":"/static/js/voice.1.2.6.js","click":"/static/js/click.3.1.2.js","beeline":"/static/js/beeline.1.0.1.js","fullpage":"/static/js/fullpage.9.2.0-guwyxh.js","slide":"/static/js/slide.7.9.3.js","geetest":"/static/js/geetest.6.0.9.js","aspect_radio":{"slide":103,"click":128,"voice":128,"beeline":50},"cc":16,"ww":true,"i":"-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1"}'
)
o = AES_O(plaintext, str_16) o = AES_O(plaintext, str_16)
i = geetest_base64_encode(o) i = geetest_base64_encode(o)
return i['res'] + i['end'] + r return i["res"] + i["end"] + r
def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str:
def get_w2(gt: str, challenge: str, c: list[int], s: str, str_16: str) -> str:
# 伪造浏览器性能数据 # 伪造浏览器性能数据
fake_timing = generate_fake_performance_timing() fake_timing = generate_fake_performance_timing()
# 映射 # 映射
@@ -45,7 +80,7 @@ def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str:
"r": fake_timing["domContentLoadedEventEnd"], "r": fake_timing["domContentLoadedEventEnd"],
"s": fake_timing["domComplete"], "s": fake_timing["domComplete"],
"t": fake_timing["loadEventStart"], "t": fake_timing["loadEventStart"],
"u": fake_timing["loadEventEnd"] "u": fake_timing["loadEventEnd"],
} }
first_time = int(round(time.time() * 1000)) # 伪造脚本开始运行时间 first_time = int(round(time.time() * 1000)) # 伪造脚本开始运行时间
@@ -55,10 +90,9 @@ def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str:
start_y=random.randint(400, 500), start_y=random.randint(400, 500),
end_x=853, end_x=853,
end_y=288, end_y=288,
start_time=first_time start_time=first_time,
) )
trajectory = process_mouse_trajectory(guiji_yuanshu_shuzu)["data"] trajectory = process_mouse_trajectory(guiji_yuanshu_shuzu)["data"]
compressed = compress_trajectory(trajectory) compressed = compress_trajectory(trajectory)
tt = encrypt_string(compressed, c, s) tt = encrypt_string(compressed, c, s)
@@ -67,14 +101,30 @@ def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str:
rp = simple_md5(gt + challenge + passtime) rp = simple_md5(gt + challenge + passtime)
plaintext = '{"lang":"zh-cn","type":"fullpage","tt":"'+tt+'","light":"DIV_0","s":"c7c3e21112fe4f741921cb3e4ff9f7cb","h":"321f9af1e098233dbd03f250fd2b5e21","hh":"39bd9cad9e425c3a8f51610fd506e3b3","hi":"09eb21b3ae9542a9bc1e8b63b3d9a467","vip_order":-1,"ct":-1,"ep":{"v":"9.2.0-guwyxh","te":false,"$_BBn":true,"ven":"Google Inc. (AMD)","ren":"ANGLE (AMD, AMD Radeon RX 6750 GRE 12GB (0x000073DF) Direct3D11 vs_5_0 ps_5_0, D3D11)","fp":'+json.dumps(guiji_yuanshu_shuzu[0], separators=(',', ':'))+',"lp":'+json.dumps(guiji_yuanshu_shuzu[-1], separators=(',', ':'))+',"em":{"ph":0,"cp":0,"ek":"11","wd":1,"nt":0,"si":0,"sc":0},"tm":'+json.dumps(web_load_time, separators=(',', ':'))+',"dnf":"dnf","by":0},"passtime":'+passtime+',"rp":"'+rp+'","captcha_token":"112439067","tsfq":"xovrayel"}' plaintext = (
'{"lang":"zh-cn","type":"fullpage","tt":"'
+ tt
+ '","light":"DIV_0","s":"c7c3e21112fe4f741921cb3e4ff9f7cb","h":"321f9af1e098233dbd03f250fd2b5e21","hh":"39bd9cad9e425c3a8f51610fd506e3b3","hi":"09eb21b3ae9542a9bc1e8b63b3d9a467","vip_order":-1,"ct":-1,"ep":{"v":"9.2.0-guwyxh","te":false,"$_BBn":true,"ven":"Google Inc. (AMD)","ren":"ANGLE (AMD, AMD Radeon RX 6750 GRE 12GB (0x000073DF) Direct3D11 vs_5_0 ps_5_0, D3D11)","fp":'
+ json.dumps(guiji_yuanshu_shuzu[0], separators=(",", ":"))
+ ',"lp":'
+ json.dumps(guiji_yuanshu_shuzu[-1], separators=(",", ":"))
+ ',"em":{"ph":0,"cp":0,"ek":"11","wd":1,"nt":0,"si":0,"sc":0},"tm":'
+ json.dumps(web_load_time, separators=(",", ":"))
+ ',"dnf":"dnf","by":0},"passtime":'
+ passtime
+ ',"rp":"'
+ rp
+ '","captcha_token":"112439067","tsfq":"xovrayel"}'
)
result = geetest_base64_encode(AES_O(plaintext, str_16)) result = geetest_base64_encode(AES_O(plaintext, str_16))
return result['res']+result['end']+result['end'] return result["res"] + result["end"] + result["end"]
def get_w3(str_16:str, challenge:str, hkjl:int, c:list[int], s:str, gt:str) -> str:
def get_w3(
str_16: str, challenge: str, hkjl: int, c: list[int], s: str, gt: str
) -> str:
encoder = TrajectoryEncoder() encoder = TrajectoryEncoder()
u = RSA_jiami_r(str_16) u = RSA_jiami_r(str_16)
# 伪造浏览器性能数据 # 伪造浏览器性能数据
@@ -101,11 +151,9 @@ def get_w3(str_16:str, challenge:str, hkjl:int, c:list[int], s:str, gt:str) -> s
"r": fake_timing["domContentLoadedEventEnd"], "r": fake_timing["domContentLoadedEventEnd"],
"s": fake_timing["domComplete"], "s": fake_timing["domComplete"],
"t": fake_timing["loadEventStart"], "t": fake_timing["loadEventStart"],
"u": fake_timing["loadEventEnd"] "u": fake_timing["loadEventEnd"],
} }
trajectory = get_slide_track(hkjl)[0] trajectory = get_slide_track(hkjl)[0]
logger.debug("滑动轨迹: {}", trajectory) logger.debug("滑动轨迹: {}", trajectory)
@@ -117,13 +165,25 @@ def get_w3(str_16:str, challenge:str, hkjl:int, c:list[int], s:str, gt:str) -> s
passtime = str(trajectory[-1][2]) passtime = str(trajectory[-1][2])
rp = simple_md5(gt + challenge[:32] + passtime) rp = simple_md5(gt + challenge[:32] + passtime)
plaintext = '{"lang":"zh-cn","userresponse":"'+userresponse+'","passtime":'+passtime+',"imgload":50,"aa":"'+aa+'","ep":{"v":"7.9.3","$_BIT":false,"me":true,"tm":'+json.dumps(web_load_time, separators=(',', ':'))+',"td":-1},"h9s9":"1816378497","rp":"'+rp+'"}' plaintext = (
'{"lang":"zh-cn","userresponse":"'
+ userresponse
+ '","passtime":'
+ passtime
+ ',"imgload":50,"aa":"'
+ aa
+ '","ep":{"v":"7.9.3","$_BIT":false,"me":true,"tm":'
+ json.dumps(web_load_time, separators=(",", ":"))
+ ',"td":-1},"h9s9":"1816378497","rp":"'
+ rp
+ '"}'
)
h = geetest_base64_encode(AES_O(plaintext, str_16))["res"]
return h + u
h = geetest_base64_encode(AES_O(plaintext, str_16))['res']
return h+u
def run_solver() -> None: def run_solver() -> None:
# 16位字符串 # 16位字符串
@@ -154,7 +214,7 @@ def run_solver() -> None:
hkjl = download_picture(bg, fullbg, slice) hkjl = download_picture(bg, fullbg, slice)
# 获取第三个w值 # 获取第三个w值
w3 = get_w3(str_16,challenge,hkjl,c,s,gt) w3 = get_w3(str_16, challenge, hkjl, c, s, gt)
# 最后的验证 # 最后的验证
message = req_end(gt, challenge, w3) message = req_end(gt, challenge, w3)
+29 -13
View File
@@ -26,6 +26,7 @@
python -m core.huya.account_env <账号> <密码> --new-device # 抛弃旧环境, 换新设备 python -m core.huya.account_env <账号> <密码> --new-device # 抛弃旧环境, 换新设备
python -m core.huya.account_env <账号> --show # 只查看该账号绑定 python -m core.huya.account_env <账号> --show # 只查看该账号绑定
""" """
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
@@ -35,10 +36,10 @@ import sys
import time import time
from .device_profile import ( from .device_profile import (
HDID32, # 登录帧 t1.t0 32hex 常量 (R15 公式结果, k1 来源见 R39; device_profile.py) HDID32, # 登录帧 t1.t0 32hex 常量 (R15 公式结果, k1 来源见 R39; device_profile.py)
_load_db, _load_db,
_save_db, _save_db,
get_profile, # 幂等画像: 同账号永远复用同一套 (data/huya_device_profiles.json) get_profile, # 幂等画像: 同账号永远复用同一套 (data/huya_device_profiles.json)
) )
from .app_login import HuyaAppPasswordLogin from .app_login import HuyaAppPasswordLogin
@@ -77,7 +78,9 @@ def get_or_create_env(account: str, force_new: bool = False) -> dict:
record["guid32"] = _rand_hex(16, b"guid") record["guid32"] = _rand_hex(16, b"guid")
changed = True changed = True
if "hebe" not in record: if "hebe" not in record:
record["hebe"] = {f"Hebe_D{i}": _rand_hex(16, f"hebe{i}".encode()) for i in range(1, 6)} record["hebe"] = {
f"Hebe_D{i}": _rand_hex(16, f"hebe{i}".encode()) for i in range(1, 6)
}
changed = True changed = True
if changed: if changed:
db[account] = record db[account] = record
@@ -88,8 +91,12 @@ def get_or_create_env(account: str, force_new: bool = False) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 绑定 + 登录: 主流程 # 绑定 + 登录: 主流程
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def bind_and_login(account: str, password: str, def bind_and_login(
force_new_device: bool = False, proxies: dict | None = None) -> dict: account: str,
password: str,
force_new_device: bool = False,
proxies: dict | None = None,
) -> dict:
"""账号 ↔ 环境绑定并登录。 """账号 ↔ 环境绑定并登录。
注册链在 login_cred_with_flow 内部执行一次 (register_device 用的 fingerprint 注册链在 login_cred_with_flow 内部执行一次 (register_device 用的 fingerprint
@@ -97,9 +104,11 @@ def bind_and_login(account: str, password: str,
沿用同一组设备字段 (app_login.login_cred_with_flow)。 沿用同一组设备字段 (app_login.login_cred_with_flow)。
""" """
env = get_or_create_env(account, force_new=force_new_device) env = get_or_create_env(account, force_new=force_new_device)
print(f"[env] {account}{env.get('vendor')}/{env.get('model')} " print(
f"fp40={env.get('fingerprint', '')[:12]}... guid32={env.get('guid32', '')[:12]}... " f"[env] {account}{env.get('vendor')}/{env.get('model')} "
f"t1.t0={env.get('hdid', '')[:12]}...") f"fp40={env.get('fingerprint', '')[:12]}... guid32={env.get('guid32', '')[:12]}... "
f"t1.t0={env.get('hdid', '')[:12]}..."
)
# device_info 显式传入本环境 (否则 HuyaAppPasswordLogin 内部会再走一次 # device_info 显式传入本环境 (否则 HuyaAppPasswordLogin 内部会再走一次
# get_profile —— 结果相同, 但显式传入让"环境→注册→登录"的数据流向可读); # get_profile —— 结果相同, 但显式传入让"环境→注册→登录"的数据流向可读);
@@ -108,13 +117,16 @@ def bind_and_login(account: str, password: str,
login_env.setdefault("device_id", env.get("device_id")) login_env.setdefault("device_id", env.get("device_id"))
result = HuyaAppPasswordLogin( result = HuyaAppPasswordLogin(
account, password, proxies=proxies, device_info=login_env, account,
password,
proxies=proxies,
device_info=login_env,
).login() ).login()
# ---- 绑定元数据回写 (令牌不入库: t2/t5 每次登录实时签发, 环境才是长期身份) ---- # ---- 绑定元数据回写 (令牌不入库: t2/t5 每次登录实时签发, 环境才是长期身份) ----
db = _load_db() db = _load_db()
record = dict(db.get(account) or env) record = dict(db.get(account) or env)
record.setdefault("bound_at", int(time.time())) # 首次绑定时间 record.setdefault("bound_at", int(time.time())) # 首次绑定时间
record["last_login"] = { record["last_login"] = {
"ok": result.success, "ok": result.success,
"msg": result.message[:120], "msg": result.message[:120],
@@ -125,9 +137,13 @@ def bind_and_login(account: str, password: str,
return { return {
"account": account, "account": account,
"env": {"model": env.get("model"), "vendor": env.get("vendor"), "env": {
"fingerprint": env.get("fingerprint"), "guid32": env.get("guid32"), "model": env.get("model"),
"hdid_t1t0": env.get("hdid")}, "vendor": env.get("vendor"),
"fingerprint": env.get("fingerprint"),
"guid32": env.get("guid32"),
"hdid_t1t0": env.get("hdid"),
},
"login_success": result.success, "login_success": result.success,
"login_message": result.message, "login_message": result.message,
"code": getattr(result, "code", None), "code": getattr(result, "code", None),
+6 -2
View File
@@ -245,7 +245,9 @@ class ScoreExchangePrizeResp(TafStruct):
self.msg = ins.read_string(1, default=self.msg) self.msg = ins.read_string(1, default=self.msg)
self.orderId = ins.read_string(3, default=self.orderId) self.orderId = ins.read_string(3, default=self.orderId)
self.exchangeInfo = ins.read_struct(4, ExchangeInfo) or self.exchangeInfo self.exchangeInfo = ins.read_struct(4, ExchangeInfo) or self.exchangeInfo
self.actPreCondition = ins.read_struct(5, ExchangeActPreCondition) or self.actPreCondition self.actPreCondition = (
ins.read_struct(5, ExchangeActPreCondition) or self.actPreCondition
)
def write_to(self, os: TafOutputStream): def write_to(self, os: TafOutputStream):
os.write_int32(0, self.status) os.write_int32(0, self.status)
@@ -512,7 +514,9 @@ class ActTaskDetailItem(TafStruct):
@property @property
def spu_id(self) -> str: def spu_id(self) -> str:
return self._extract_spu_id(self.taskUrl) or self._extract_spu_id(self.taskParams) return self._extract_spu_id(self.taskUrl) or self._extract_spu_id(
self.taskParams
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
+14 -4
View File
@@ -172,7 +172,11 @@ def register_huya_with_sms_line(
if login_result.success and login_result.cookie: if login_result.success and login_result.cookie:
cookie = login_result.cookie cookie = login_result.cookie
uid = cookie_value(cookie, "udb_uid") or cookie_value(cookie, "yyuid") uid = cookie_value(cookie, "udb_uid") or cookie_value(cookie, "yyuid")
username = cookie_value(cookie, "udb_passport") or cookie_value(cookie, "username") or uid username = (
cookie_value(cookie, "udb_passport")
or cookie_value(cookie, "username")
or uid
)
if not change_password: if not change_password:
return HuyaAutoRegisterResult( return HuyaAutoRegisterResult(
phone=phone, phone=phone,
@@ -207,7 +211,9 @@ def register_huya_with_sms_line(
attempts=attempts, attempts=attempts,
) )
password = fixed_password.strip() or generate_huya_password(password_prefix) password = fixed_password.strip() or generate_huya_password(
password_prefix
)
change_result = change_huya_password_with_sms_line( change_result = change_huya_password_with_sms_line(
uid=uid, uid=uid,
cookie=cookie, cookie=cookie,
@@ -220,7 +226,9 @@ def register_huya_with_sms_line(
stop_event=stop_event, stop_event=stop_event,
) )
if not change_result.success: if not change_result.success:
failed_status = "stopped" if change_result.message == "已停止" else "error" failed_status = (
"stopped" if change_result.message == "已停止" else "error"
)
return HuyaAutoRegisterResult( return HuyaAutoRegisterResult(
phone=phone, phone=phone,
provider=item.provider, provider=item.provider,
@@ -230,7 +238,9 @@ def register_huya_with_sms_line(
cookie=cookie, cookie=cookie,
code=poll_result.code, code=poll_result.code,
change_code=change_result.code, change_code=change_result.code,
sdid=change_result.sdid or login_result.sdid or code_result.sdid, sdid=change_result.sdid
or login_result.sdid
or code_result.sdid,
normalized_phone=normalized_phone, normalized_phone=normalized_phone,
username=username, username=username,
uid=uid, uid=uid,
+2 -1
View File
@@ -2,6 +2,7 @@
证书格式:base64( [0x0c][key_idx][AES-128-ECB(key16, zeropad(P1))] ) 证书格式:base64( [0x0c][key_idx][AES-128-ECB(key16, zeropad(P1))] )
""" """
from __future__ import annotations from __future__ import annotations
import base64 import base64
@@ -48,7 +49,7 @@ def parse_p1(data: bytes) -> dict:
def tk(n: int) -> bytes: def tk(n: int) -> bytes:
nonlocal o nonlocal o
b = data[o:o + n] b = data[o : o + n]
o += n o += n
return b return b
+12 -6
View File
@@ -29,9 +29,12 @@ FP_STATE_ROOT = Path(__file__).resolve().parents[2] / "data" / "huya_fp_states"
def account_state_dir(account: str) -> Path: def account_state_dir(account: str) -> Path:
"""账号专属指纹状态目录 (持久化, 保证同一账号多次登录是同一台'设备')。""" """账号专属指纹状态目录 (持久化, 保证同一账号多次登录是同一台'设备')。"""
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in (account or "anon"))[:64] safe = "".join(
c if c.isalnum() or c in "-_." else "_" for c in (account or "anon")
)[:64]
return FP_STATE_ROOT / safe return FP_STATE_ROOT / safe
DEFAULT_TIMEOUT = (8, 40) DEFAULT_TIMEOUT = (8, 40)
@@ -52,7 +55,9 @@ class HuyaSdidResult:
HDID_PREFIX = "__HDID__" HDID_PREFIX = "__HDID__"
def _run_node_runner(state_dir: Path, app_id: str, timeout: tuple[float, float]) -> tuple[str, str]: def _run_node_runner(
state_dir: Path, app_id: str, timeout: tuple[float, float]
) -> tuple[str, str]:
"""调用 node runner,返回 (sdid, hdid)。 """调用 node runner,返回 (sdid, hdid)。
若 state_dir/device.json 存在 (账号画像派生的设备覆盖参数), runner 会以该 若 state_dir/device.json 存在 (账号画像派生的设备覆盖参数), runner 会以该
@@ -79,9 +84,9 @@ def _run_node_runner(state_dir: Path, app_id: str, timeout: tuple[float, float])
for line in (proc.stdout or "").splitlines(): for line in (proc.stdout or "").splitlines():
line = line.strip() line = line.strip()
if line.startswith(SDID_PREFIX) and len(line) > len(SDID_PREFIX) + 20: if line.startswith(SDID_PREFIX) and len(line) > len(SDID_PREFIX) + 20:
sdid = line[len(SDID_PREFIX):] sdid = line[len(SDID_PREFIX) :]
if line.startswith(HDID_PREFIX) and len(line) > len(HDID_PREFIX) + 20: if line.startswith(HDID_PREFIX) and len(line) > len(HDID_PREFIX) + 20:
hdid = line[len(HDID_PREFIX):] hdid = line[len(HDID_PREFIX) :]
if sdid: if sdid:
return sdid, hdid return sdid, hdid
stderr_tail = (proc.stderr or "").strip().splitlines() stderr_tail = (proc.stderr or "").strip().splitlines()
@@ -168,8 +173,9 @@ def get_huya_sdid(
try: try:
sdid, hdid = _run_node_runner(state_dir, app_id, timeout) sdid, hdid = _run_node_runner(state_dir, app_id, timeout)
if sdid: if sdid:
logger.debug("虎牙设备指纹成功(node): sdid={}... hdid={}...", logger.debug(
sdid[:24], hdid[:10]) "虎牙设备指纹成功(node): sdid={}... hdid={}...", sdid[:24], hdid[:10]
)
return HuyaSdidResult(sdid=sdid, hdid=hdid, source="fingerprint") return HuyaSdidResult(sdid=sdid, hdid=hdid, source="fingerprint")
except HuyaFingerprintError as exc: except HuyaFingerprintError as exc:
logger.warning("虎牙 hydevice 指纹失败: {}", exc) logger.warning("虎牙 hydevice 指纹失败: {}", exc)
+11 -3
View File
@@ -12,6 +12,7 @@
- 固定: hdid(32hex 硬锚,全账号同一) / app_version / sdk_version - 固定: hdid(32hex 硬锚,全账号同一) / app_version / sdk_version
- 动态签发: safedeviceid、登录帧 device_id。 - 动态签发: safedeviceid、登录帧 device_id。
""" """
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
@@ -85,6 +86,7 @@ def record_login(account: str, ok: bool, message: str = "") -> None:
供 app_login 登录流程调用, GUI 设备绑定页读取展示。 供 app_login 登录流程调用, GUI 设备绑定页读取展示。
""" """
import time as _time import time as _time
db = _load_db() db = _load_db()
rec = db.get(account) rec = db.get(account)
if rec is None: if rec is None:
@@ -112,7 +114,9 @@ def _load_db() -> dict:
def _save_db(db: dict) -> None: def _save_db(db: dict) -> None:
try: try:
PRIMARY_PROFILE_DB.parent.mkdir(parents=True, exist_ok=True) PRIMARY_PROFILE_DB.parent.mkdir(parents=True, exist_ok=True)
PRIMARY_PROFILE_DB.write_text(json.dumps(db, indent=2, ensure_ascii=False), encoding="utf-8") PRIMARY_PROFILE_DB.write_text(
json.dumps(db, indent=2, ensure_ascii=False), encoding="utf-8"
)
except Exception: except Exception:
pass pass
@@ -130,8 +134,12 @@ def _enrich_profile(profile: dict) -> tuple[dict, bool]:
out["guid32"] = hashlib.sha256(os.urandom(16) + b"guid").hexdigest() out["guid32"] = hashlib.sha256(os.urandom(16) + b"guid").hexdigest()
changed = True changed = True
if len(out.get("hebe") or {}) < 5: if len(out.get("hebe") or {}) < 5:
out["hebe"] = {f"Hebe_D{i}": hashlib.sha256(os.urandom(16) + f"hebe{i}".encode()).hexdigest() out["hebe"] = {
for i in range(1, 6)} f"Hebe_D{i}": hashlib.sha256(
os.urandom(16) + f"hebe{i}".encode()
).hexdigest()
for i in range(1, 6)
}
changed = True changed = True
return out, changed return out, changed
+17 -6
View File
@@ -2,6 +2,7 @@
基于 XXTEA 算法与 uid + k1 派生密钥。 基于 XXTEA 算法与 uid + k1 派生密钥。
""" """
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
@@ -27,13 +28,23 @@ def _xxtea_encrypt_words(v: list[int], k: list[int]) -> list[int]:
p = 0 p = 0
while p < n - 1: while p < n - 1:
y = v[p + 1] y = v[p + 1]
z = (v[p] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) z = (
^ ((s ^ y) + (k[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF v[p]
+ (
(((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
^ ((s ^ y) + (k[(p & 3) ^ e] ^ z))
)
) & 0xFFFFFFFF
v[p] = z v[p] = z
p += 1 p += 1
y = v[0] y = v[0]
z = (v[n - 1] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) z = (
^ ((s ^ y) + (k[((n - 1) & 3) ^ e] ^ z)))) & 0xFFFFFFFF v[n - 1]
+ (
(((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
^ ((s ^ y) + (k[((n - 1) & 3) ^ e] ^ z))
)
) & 0xFFFFFFFF
v[n - 1] = z v[n - 1] = z
q -= 1 q -= 1
return v return v
@@ -45,9 +56,9 @@ def xxtea_encrypt(data: bytes, key16: bytes) -> bytes:
nwords = (n // 4) + 1 nwords = (n // 4) + 1
v = [0] * nwords v = [0] * nwords
for i in range(n // 4): for i in range(n // 4):
v[i] = struct.unpack("<I", data[i * 4:i * 4 + 4])[0] v[i] = struct.unpack("<I", data[i * 4 : i * 4 + 4])[0]
v[nwords - 1] = n v[nwords - 1] = n
k = [struct.unpack("<I", key16[i * 4:i * 4 + 4])[0] for i in range(4)] k = [struct.unpack("<I", key16[i * 4 : i * 4 + 4])[0] for i in range(4)]
_xxtea_encrypt_words(v, k) _xxtea_encrypt_words(v, k)
return b"".join(struct.pack("<I", w & 0xFFFFFFFF) for w in v) return b"".join(struct.pack("<I", w & 0xFFFFFFFF) for w in v)
+145 -120
View File
@@ -4,6 +4,7 @@
来源:m-shop.yaoguo.com 的 jce/ShopFacade.js、api/orderui.ts、api/mall/PlayMallNewHome.ts 来源:m-shop.yaoguo.com 的 jce/ShopFacade.js、api/orderui.ts、api/mall/PlayMallNewHome.ts
""" """
from typing import List, Dict, Optional from typing import List, Dict, Optional
from .taf_protocol import TafOutputStream, TafInputStream, TafStruct, TafType from .taf_protocol import TafOutputStream, TafInputStream, TafStruct, TafType
@@ -78,17 +79,19 @@ def _skip_to_struct_end(ins: TafInputStream):
# 基础结构 # 基础结构
# ============================================================ # ============================================================
class UserId(TafStruct): class UserId(TafStruct):
"""用户标识(cookie 在这里)""" """用户标识(cookie 在这里)"""
def __init__(self): def __init__(self):
self.lUid: int = 0 # tag 0 self.lUid: int = 0 # tag 0
self.sGuid: str = "" # tag 1 self.sGuid: str = "" # tag 1
self.sToken: str = "" # tag 2 self.sToken: str = "" # tag 2
self.sHuYaUA: str = "" # tag 3 "webh5&0.0.1&websocket&&diypc_52775" self.sHuYaUA: str = "" # tag 3 "webh5&0.0.1&websocket&&diypc_52775"
self.sCookie: str = "" # tag 4 完整 cookie self.sCookie: str = "" # tag 4 完整 cookie
self.iTokenType: int = 0 # tag 5 self.iTokenType: int = 0 # tag 5
self.sDeviceInfo: str = "" # tag 6 self.sDeviceInfo: str = "" # tag 6
self.sQIMEI: str = "" # tag 7 self.sQIMEI: str = "" # tag 7
def write_to(self, os: TafOutputStream): def write_to(self, os: TafOutputStream):
# HAR2 实证: 浏览器不优化空串/0值, 全写字段 # HAR2 实证: 浏览器不优化空串/0值, 全写字段
@@ -114,11 +117,12 @@ class UserId(TafStruct):
class ShopAppInfo(TafStruct): class ShopAppInfo(TafStruct):
"""应用信息(HAR2 实证字段顺序: tag0 sAppId, tag1 sBizType, tag4 scene, tag5 sourceId""" """应用信息(HAR2 实证字段顺序: tag0 sAppId, tag1 sBizType, tag4 scene, tag5 sourceId"""
def __init__(self): def __init__(self):
self.sAppId: str = "huya" # tag 0 self.sAppId: str = "huya" # tag 0
self.sBizType: str = "" # tag 1 self.sBizType: str = "" # tag 1
self.scene: int = 0 # tag 4 self.scene: int = 0 # tag 4
self.sourceId: str = "" # tag 5 self.sourceId: str = "" # tag 5
def write_to(self, os: TafOutputStream): def write_to(self, os: TafOutputStream):
# HAR2 实证: 全写字段(含空串/0) # HAR2 实证: 全写字段(含空串/0)
@@ -138,14 +142,16 @@ class ShopAppInfo(TafStruct):
# wsLaunch 初始化 # wsLaunch 初始化
# ============================================================ # ============================================================
class WsLaunchSubStruct(TafStruct): class WsLaunchSubStruct(TafStruct):
"""wsLaunch tag4 子结构(5个空字符串字段,浏览器强制写)""" """wsLaunch tag4 子结构(5个空字符串字段,浏览器强制写)"""
def __init__(self): def __init__(self):
self.s0: str = "" # tag 0 self.s0: str = "" # tag 0
self.s1: str = "" # tag 1 self.s1: str = "" # tag 1
self.s2: str = "" # tag 2 self.s2: str = "" # tag 2
self.s3: str = "" # tag 3 self.s3: str = "" # tag 3
self.s4: str = "" # tag 4 self.s4: str = "" # tag 4
def write_to(self, os: TafOutputStream): def write_to(self, os: TafOutputStream):
# 浏览器写空字符串(STRING1 length0),这里强制写以精确匹配 # 浏览器写空字符串(STRING1 length0),这里强制写以精确匹配
@@ -169,11 +175,12 @@ class WsLaunchReq(TafStruct):
tag3: appSrc "HUYA&ZH&2052" tag3: appSrc "HUYA&ZH&2052"
tag4: 子struct (5个空字符串) tag4: 子struct (5个空字符串)
""" """
def __init__(self): def __init__(self):
self.lUid: int = 0 # tag 0 self.lUid: int = 0 # tag 0
self.s1: str = "" # tag 1 self.s1: str = "" # tag 1
self.sHuYaUA: str = "webh5&1.0.0&huya" # tag 2 self.sHuYaUA: str = "webh5&1.0.0&huya" # tag 2
self.appSrc: str = "HUYA&ZH&2052" # tag 3 self.appSrc: str = "HUYA&ZH&2052" # tag 3
self.sub: WsLaunchSubStruct = WsLaunchSubStruct() # tag 4 self.sub: WsLaunchSubStruct = WsLaunchSubStruct() # tag 4
def write_to(self, os: TafOutputStream): def write_to(self, os: TafOutputStream):
@@ -192,18 +199,20 @@ class WsLaunchReq(TafStruct):
# 商品查询 # 商品查询
# ============================================================ # ============================================================
class GetGoodsInfoReqV5(TafStruct): class GetGoodsInfoReqV5(TafStruct):
"""商品查询请求 (shopMiddleUI.getGoodsInfoV5)""" """商品查询请求 (shopMiddleUI.getGoodsInfoV5)"""
def __init__(self): def __init__(self):
self.userId = UserId() # tag 0 self.userId = UserId() # tag 0
self.shopAppInfo = ShopAppInfo() # tag 1 self.shopAppInfo = ShopAppInfo() # tag 1
self.pid: int = 0 # tag 2 self.pid: int = 0 # tag 2
self.gameId: str = "" # tag 3 self.gameId: str = "" # tag 3
self.spuId: str = "" # tag 4 self.spuId: str = "" # tag 4
self.channelStockCode: str = "" # tag 5 self.channelStockCode: str = "" # tag 5
self.skuId: int = 0 # tag 6 self.skuId: int = 0 # tag 6
self.inviterUid: int = 0 # tag 7 self.inviterUid: int = 0 # tag 7
self.userModifyPriceId: int = 0 # tag 8 self.userModifyPriceId: int = 0 # tag 8
def write_to(self, os: TafOutputStream): def write_to(self, os: TafOutputStream):
# HAR2 实证: 全写字段 # HAR2 实证: 全写字段
@@ -224,13 +233,14 @@ class GetGoodsInfoReqV5(TafStruct):
class GoodsInfoRsp(TafStruct): class GoodsInfoRsp(TafStruct):
"""商品查询响应。""" """商品查询响应。"""
def __init__(self): def __init__(self):
self.code: int = 0 # tag 0 self.code: int = 0 # tag 0
self.message: str = "" # tag 1 self.message: str = "" # tag 1
self.goodsInfo = None # tag 2 self.goodsInfo = None # tag 2
self.selfGoods: int = 0 # tag 3 self.selfGoods: int = 0 # tag 3
self.marketStatus: int = 0 # tag 5 self.marketStatus: int = 0 # tag 5
self.timestamp: int = 0 # tag 6 self.timestamp: int = 0 # tag 6
def read_from(self, ins: TafInputStream): def read_from(self, ins: TafInputStream):
self.code = ins.read_int32(0, default=self.code) self.code = ins.read_int32(0, default=self.code)
@@ -293,6 +303,7 @@ class GoodsInfoRsp(TafStruct):
class GoodsBaseInfo(TafStruct): class GoodsBaseInfo(TafStruct):
"""商品基础信息(getGoodsInfoV5 tag2.tag0)。""" """商品基础信息(getGoodsInfoV5 tag2.tag0)。"""
def __init__(self): def __init__(self):
self.spuId: str = "" self.spuId: str = ""
self.appId: str = "" self.appId: str = ""
@@ -328,6 +339,7 @@ class GoodsBaseInfo(TafStruct):
class GoodsSkuItem(TafStruct): class GoodsSkuItem(TafStruct):
"""商品 SKU 信息(getGoodsInfoV5 tag2.tag4.tag3 map value)。""" """商品 SKU 信息(getGoodsInfoV5 tag2.tag4.tag3 map value)。"""
def __init__(self): def __init__(self):
self.skuId: int = 0 self.skuId: int = 0
self.spuId: str = "" self.spuId: str = ""
@@ -370,6 +382,7 @@ class GoodsSkuItem(TafStruct):
class GoodsPriceInfo(TafStruct): class GoodsPriceInfo(TafStruct):
"""商品价格与 SKU 信息(getGoodsInfoV5 tag2.tag4)。""" """商品价格与 SKU 信息(getGoodsInfoV5 tag2.tag4)。"""
def __init__(self): def __init__(self):
self.spuId: str = "" self.spuId: str = ""
self.minPrice: int = 0 self.minPrice: int = 0
@@ -435,6 +448,7 @@ class GoodsPriceInfo(TafStruct):
class GoodsInfoDetail(TafStruct): class GoodsInfoDetail(TafStruct):
"""getGoodsInfoV5 响应里的 goodsInfo 主体。""" """getGoodsInfoV5 响应里的 goodsInfo 主体。"""
def __init__(self): def __init__(self):
self.baseInfo = GoodsBaseInfo() self.baseInfo = GoodsBaseInfo()
self.priceInfo = GoodsPriceInfo() self.priceInfo = GoodsPriceInfo()
@@ -457,8 +471,10 @@ class GoodsInfoDetail(TafStruct):
# 订单历史 # 订单历史
# ============================================================ # ============================================================
class OrderListShopInfo(TafStruct): class OrderListShopInfo(TafStruct):
"""订单明细里的店铺信息(只取展示需要的字段)""" """订单明细里的店铺信息(只取展示需要的字段)"""
def __init__(self): def __init__(self):
self.shopName: str = "" # tag 0 self.shopName: str = "" # tag 0
@@ -477,14 +493,15 @@ class OrderListShopInfo(TafStruct):
class OrderListGoodsDetail(TafStruct): class OrderListGoodsDetail(TafStruct):
"""订单明细(queryUserOrderList 响应 tag16""" """订单明细(queryUserOrderList 响应 tag16"""
def __init__(self): def __init__(self):
self.spuId: str = "" # tag 4 self.spuId: str = "" # tag 4
self.skuId: int = 0 # tag 16 self.skuId: int = 0 # tag 16
self.buyerUid: int = 0 # tag 18 self.buyerUid: int = 0 # tag 18
self.virtualType: int = 0 # tag 19 self.virtualType: int = 0 # tag 19
self.quantity: int = 0 # tag 20 self.quantity: int = 0 # tag 20
self.shopInfo: Optional[OrderListShopInfo] = None # tag 21 self.shopInfo: Optional[OrderListShopInfo] = None # tag 21
self.points: int = 0 # tag 23 self.points: int = 0 # tag 23
def read_from(self, ins: TafInputStream): def read_from(self, ins: TafInputStream):
self.spuId = ins.read_string(4, default=self.spuId) self.spuId = ins.read_string(4, default=self.spuId)
@@ -513,19 +530,20 @@ class OrderListGoodsDetail(TafStruct):
class OrderListItem(TafStruct): class OrderListItem(TafStruct):
"""订单列表项(queryUserOrderList 响应 tag3 的 list 元素)""" """订单列表项(queryUserOrderList 响应 tag3 的 list 元素)"""
def __init__(self): def __init__(self):
self.bizOrderId: str = "" # tag 0 shop10148750 self.bizOrderId: str = "" # tag 0 shop10148750
self.appId: str = "" # tag 1 shop self.appId: str = "" # tag 1 shop
self.orderId: str = "" # tag 2 self.orderId: str = "" # tag 2
self.pid: int = 0 # tag 3 self.pid: int = 0 # tag 3
self.shopName: str = "" # tag 4 self.shopName: str = "" # tag 4
self.orderStatus: int = 0 # tag 5 self.orderStatus: int = 0 # tag 5
self.itemName: str = "" # tag 8 self.itemName: str = "" # tag 8
self.unitPrice: int = 0 # tag 9 分 self.unitPrice: int = 0 # tag 9 分
self.quantity: int = 0 # tag 10 self.quantity: int = 0 # tag 10
self.totalPrice: int = 0 # tag 12 分 self.totalPrice: int = 0 # tag 12 分
self.createTime: int = 0 # tag 14 毫秒时间戳 self.createTime: int = 0 # tag 14 毫秒时间戳
self.payTime: int = 0 # tag 15 毫秒时间戳 self.payTime: int = 0 # tag 15 毫秒时间戳
self.goodsDetail: Optional[OrderListGoodsDetail] = None # tag 16 self.goodsDetail: Optional[OrderListGoodsDetail] = None # tag 16
def read_from(self, ins: TafInputStream): def read_from(self, ins: TafInputStream):
@@ -567,12 +585,13 @@ class OrderListItem(TafStruct):
class QueryUserOrderListReq(TafStruct): class QueryUserOrderListReq(TafStruct):
"""查询购买历史订单 (revenueWebUI.queryUserOrderList)""" """查询购买历史订单 (revenueWebUI.queryUserOrderList)"""
def __init__(self): def __init__(self):
self.userId = UserId() # tag 0 self.userId = UserId() # tag 0
self.offset: int = 0 # tag 1 self.offset: int = 0 # tag 1
self.orderType: int = 1 # tag 2 self.orderType: int = 1 # tag 2
self.pageSize: int = 10 # tag 3 self.pageSize: int = 10 # tag 3
self.status: int = 0 # tag 4 self.status: int = 0 # tag 4
def write_to(self, os: TafOutputStream): def write_to(self, os: TafOutputStream):
os.write_struct(0, self.userId) os.write_struct(0, self.userId)
@@ -587,6 +606,7 @@ class QueryUserOrderListReq(TafStruct):
class QueryUserOrderListRsp(TafStruct): class QueryUserOrderListRsp(TafStruct):
"""购买历史订单响应""" """购买历史订单响应"""
def __init__(self): def __init__(self):
self.code: int = 0 self.code: int = 0
self.message: str = "" self.message: str = ""
@@ -624,17 +644,18 @@ class QueryUserOrderListRsp(TafStruct):
# 下单 # 下单
# ============================================================ # ============================================================
class CreateOrderExtraParam(TafStruct): class CreateOrderExtraParam(TafStruct):
def __init__(self): def __init__(self):
self.freight: int = 0 # tag 0 self.freight: int = 0 # tag 0
self.channelStockType: str = "" # tag 1 self.channelStockType: str = "" # tag 1
self.channelStockCode: str = "" # tag 2 self.channelStockCode: str = "" # tag 2
self.relatedBizId: str = "" # tag 3 self.relatedBizId: str = "" # tag 3
self.bizParams: str = "" # tag 4 self.bizParams: str = "" # tag 4
self.popupTraceId: str = "" # tag 5 self.popupTraceId: str = "" # tag 5
self.supplierUid: int = 0 # tag 6 self.supplierUid: int = 0 # tag 6
self.categoryId: str = "" # tag 7 self.categoryId: str = "" # tag 7
self.ext: str = "" # tag 8 self.ext: str = "" # tag 8
def write_to(self, os: TafOutputStream): def write_to(self, os: TafOutputStream):
# HAR 实证:下单 extraParam 会强制写默认 0/空串字段 # HAR 实证:下单 extraParam 会强制写默认 0/空串字段
@@ -655,9 +676,9 @@ class CreateOrderExtraParam(TafStruct):
class CreateOrderPromotionParam(TafStruct): class CreateOrderPromotionParam(TafStruct):
def __init__(self): def __init__(self):
self.yxjDeductPrice: int = 0 # tag 0 self.yxjDeductPrice: int = 0 # tag 0
self.userModifyPriceId: int = 0 # tag 1 self.userModifyPriceId: int = 0 # tag 1
self.enablePromotion: int = 1 # tag 2 self.enablePromotion: int = 1 # tag 2
def write_to(self, os: TafOutputStream): def write_to(self, os: TafOutputStream):
# HAR 实证:tag0/tag1 为 0tag2 为 1 # HAR 实证:tag0/tag1 为 0tag2 为 1
@@ -671,11 +692,11 @@ class CreateOrderPromotionParam(TafStruct):
class CreateOrderAccountParam(TafStruct): class CreateOrderAccountParam(TafStruct):
def __init__(self): def __init__(self):
self.payoutTypeList: List[int] = [] # tag 0 Vector<INT32> self.payoutTypeList: List[int] = [] # tag 0 Vector<INT32>
self.payoutChargeAmount: int = 0 # tag 1 self.payoutChargeAmount: int = 0 # tag 1
self.cancelPayoutTypeList: List[int] = [] # tag 2 self.cancelPayoutTypeList: List[int] = [] # tag 2
self.recycleSupplierId: int = 0 # tag 3 self.recycleSupplierId: int = 0 # tag 3
self.claimPrice: int = 0 # tag 4 self.claimPrice: int = 0 # tag 4
def write_to(self, os: TafOutputStream): def write_to(self, os: TafOutputStream):
# HAR 实证:空 list/0 值也会写出 # HAR 实证:空 list/0 值也会写出
@@ -691,8 +712,8 @@ class CreateOrderAccountParam(TafStruct):
class PromotionItem(TafStruct): class PromotionItem(TafStruct):
def __init__(self): def __init__(self):
self.promotionId: int = 0 # tag 0 self.promotionId: int = 0 # tag 0
self.promotionType: int = 0 # tag 1 self.promotionType: int = 0 # tag 1
def write_to(self, os: TafOutputStream): def write_to(self, os: TafOutputStream):
_opt_int(os, 0, self.promotionId) _opt_int(os, 0, self.promotionId)
@@ -705,40 +726,41 @@ class PromotionItem(TafStruct):
class CreateOrderReqV5(TafStruct): class CreateOrderReqV5(TafStruct):
"""下单请求 (shopMiddleUI.createOrderV5)""" """下单请求 (shopMiddleUI.createOrderV5)"""
def __init__(self): def __init__(self):
self.userId = UserId() # tag 0 self.userId = UserId() # tag 0
self.shopAppInfo = ShopAppInfo() # tag 1 self.shopAppInfo = ShopAppInfo() # tag 1
self.receiveId: int = 0 # tag 2 self.receiveId: int = 0 # tag 2
self.pid: int = 0 # tag 3 self.pid: int = 0 # tag 3
self.skuId: int = 0 # tag 4 self.skuId: int = 0 # tag 4
self.itemCount: int = 1 # tag 5 self.itemCount: int = 1 # tag 5
self.remark: str = "" # tag 6 self.remark: str = "" # tag 6
self.spuId: str = "" # tag 7 self.spuId: str = "" # tag 7
self.gameId: str = "" # tag 8 self.gameId: str = "" # tag 8
self.orderId: int = 0 # tag 9 self.orderId: int = 0 # tag 9
self.src: int = 0 # tag 10 self.src: int = 0 # tag 10
self.couponUserIds: List[int] = [] # tag 11 Vector<INT64> self.couponUserIds: List[int] = [] # tag 11 Vector<INT64>
self.orderType: int = 0 # tag 12 self.orderType: int = 0 # tag 12
self.extraParam: Optional[CreateOrderExtraParam] = None # tag 13 self.extraParam: Optional[CreateOrderExtraParam] = None # tag 13
self.scene: int = 0 # tag 14 self.scene: int = 0 # tag 14
self.promotionItems: List = [] # tag 15 Vector<PromotionItem> self.promotionItems: List = [] # tag 15 Vector<PromotionItem>
self.sourceId: str = "" # tag 16 self.sourceId: str = "" # tag 16
self.env: Dict[str, str] = {} # tag 17 Map<STRING,STRING> self.env: Dict[str, str] = {} # tag 17 Map<STRING,STRING>
self.orderScene: int = 0 # tag 18 self.orderScene: int = 0 # tag 18
self.watchWord: str = "" # tag 19 self.watchWord: str = "" # tag 19
self.marketingChannel: str = "" # tag 20 self.marketingChannel: str = "" # tag 20
self.promotionParam: Optional[CreateOrderPromotionParam] = None # tag 21 self.promotionParam: Optional[CreateOrderPromotionParam] = None # tag 21
self.externalTraceKey: str = "" # tag 22 self.externalTraceKey: str = "" # tag 22
self.kefuUid: int = 0 # tag 23 self.kefuUid: int = 0 # tag 23
self.accountParam: Optional[CreateOrderAccountParam] = None # tag 24 self.accountParam: Optional[CreateOrderAccountParam] = None # tag 24
self.parentOrderId: int = 0 # tag 25 self.parentOrderId: int = 0 # tag 25
self.vendorAccountType: str = "" # tag 26 self.vendorAccountType: str = "" # tag 26
self.vendorAccountVal: str = "" # tag 27 self.vendorAccountVal: str = "" # tag 27
self.vendorSubAccountVal: str = "" # tag 28 self.vendorSubAccountVal: str = "" # tag 28
self.vendorSubAccountType: str = "" # tag 29 self.vendorSubAccountType: str = "" # tag 29
self.bizType: int = 0 # tag 30 self.bizType: int = 0 # tag 30
self.gameCategoryId: int = 0 # tag 31 self.gameCategoryId: int = 0 # tag 31
self.ext: str = "" # tag 32 self.ext: str = "" # tag 32
def write_to(self, os: TafOutputStream): def write_to(self, os: TafOutputStream):
# HAR 实证:createOrderV5 会写出完整字段,即使值为 0/空串/空 list # HAR 实证:createOrderV5 会写出完整字段,即使值为 0/空串/空 list
@@ -782,13 +804,14 @@ class CreateOrderReqV5(TafStruct):
class CreateOrderRsp(TafStruct): class CreateOrderRsp(TafStruct):
"""下单响应 (shopMiddleUI.createOrderV5)""" """下单响应 (shopMiddleUI.createOrderV5)"""
def __init__(self): def __init__(self):
self.code: int = 0 # tag 0 self.code: int = 0 # tag 0
self.message: str = "" # tag 1 self.message: str = "" # tag 1
self.orderId: int = 0 # tag 2 虎牙订单号 self.orderId: int = 0 # tag 2 虎牙订单号
self.subOrderId: int = 0 # tag 4 self.subOrderId: int = 0 # tag 4
self.orderStatus: int = 0 # tag 5 self.orderStatus: int = 0 # tag 5
self.riskUrl: str = "" # tag 8 风控跳转URLcode!=200时可能有) self.riskUrl: str = "" # tag 8 风控跳转URLcode!=200时可能有)
def read_from(self, ins: TafInputStream): def read_from(self, ins: TafInputStream):
self.code = ins.read_int32(0, default=self.code) self.code = ins.read_int32(0, default=self.code)
@@ -806,19 +829,21 @@ class CreateOrderRsp(TafStruct):
# 支付(payOrderSubmitV5 # 支付(payOrderSubmitV5
# ============================================================ # ============================================================
class PayOrderRes(TafStruct): class PayOrderRes(TafStruct):
""" """
发起支付响应 (shopMiddleUI.payOrderSubmitV5) 发起支付响应 (shopMiddleUI.payOrderSubmitV5)
结构从 state_shop_ts.js 的 payOrderRes 推断,tag 顺序按出现顺序 结构从 state_shop_ts.js 的 payOrderRes 推断,tag 顺序按出现顺序
""" """
def __init__(self): def __init__(self):
self.code: int = 0 # tag 0 self.code: int = 0 # tag 0
self.message: str = "" # tag 1 self.message: str = "" # tag 1
self.orderId: int = 0 # tag 2 虎牙订单号 self.orderId: int = 0 # tag 2 虎牙订单号
self.appOrderId: str = "" # tag 3 self.appOrderId: str = "" # tag 3
self.payOrderId: str = "" # tag 4 支付宝 payOrderId self.payOrderId: str = "" # tag 4 支付宝 payOrderId
self.payUrl: str = "" # tag 5 支付宝下单URL(含sign) self.payUrl: str = "" # tag 5 支付宝下单URL(含sign)
self.amount: int = 0 # tag 6 self.amount: int = 0 # tag 6
def read_from(self, ins: TafInputStream): def read_from(self, ins: TafInputStream):
self.code = ins.read_int32(0, default=self.code) self.code = ins.read_int32(0, default=self.code)
+69 -52
View File
@@ -8,6 +8,7 @@
0x08 MAP 0x09 LIST 0x0a STRUCT_BEGIN 0x0b STRUCT_END 0x08 MAP 0x09 LIST 0x0a STRUCT_BEGIN 0x0b STRUCT_END
0x0c ZERO 0x0d SIMPLE_LIST 0x0c ZERO 0x0d SIMPLE_LIST
""" """
import struct import struct
import io import io
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@@ -24,16 +25,17 @@ class TafType:
STRING4 = 0x07 STRING4 = 0x07
MAP = 0x08 MAP = 0x08
LIST = 0x09 LIST = 0x09
STRUCT_BEGIN = 0x0a STRUCT_BEGIN = 0x0A
STRUCT_END = 0x0b STRUCT_END = 0x0B
ZERO = 0x0c ZERO = 0x0C
SIMPLE_LIST = 0x0d SIMPLE_LIST = 0x0D
# ============================================================ # ============================================================
# 输出流(编码) # 输出流(编码)
# ============================================================ # ============================================================
class TafOutputStream: class TafOutputStream:
"""TAF 编码输出流""" """TAF 编码输出流"""
@@ -46,9 +48,9 @@ class TafOutputStream:
# ---- head ---- # ---- head ----
def write_head(self, tag: int, data_type: int): def write_head(self, tag: int, data_type: int):
if tag < 15: if tag < 15:
self.buf.write(struct.pack('B', (tag << 4) | data_type)) self.buf.write(struct.pack("B", (tag << 4) | data_type))
else: else:
self.buf.write(struct.pack('BB', 0xF0 | data_type, tag)) self.buf.write(struct.pack("BB", 0xF0 | data_type, tag))
# ---- 整数(带自动优化) ---- # ---- 整数(带自动优化) ----
def write_int8(self, tag: int, value: int): def write_int8(self, tag: int, value: int):
@@ -56,28 +58,28 @@ class TafOutputStream:
self.write_head(tag, TafType.ZERO) self.write_head(tag, TafType.ZERO)
else: else:
self.write_head(tag, TafType.INT8) self.write_head(tag, TafType.INT8)
self.buf.write(struct.pack('b', value)) self.buf.write(struct.pack("b", value))
def write_int16(self, tag: int, value: int): def write_int16(self, tag: int, value: int):
if -128 <= value <= 127: if -128 <= value <= 127:
self.write_int8(tag, value) self.write_int8(tag, value)
else: else:
self.write_head(tag, TafType.INT16) self.write_head(tag, TafType.INT16)
self.buf.write(struct.pack('>h', value)) self.buf.write(struct.pack(">h", value))
def write_int32(self, tag: int, value: int): def write_int32(self, tag: int, value: int):
if -32768 <= value <= 32767: if -32768 <= value <= 32767:
self.write_int16(tag, value) self.write_int16(tag, value)
else: else:
self.write_head(tag, TafType.INT32) self.write_head(tag, TafType.INT32)
self.buf.write(struct.pack('>i', value)) self.buf.write(struct.pack(">i", value))
def write_int64(self, tag: int, value: int): def write_int64(self, tag: int, value: int):
if -2147483648 <= value <= 2147483647: if -2147483648 <= value <= 2147483647:
self.write_int32(tag, value) self.write_int32(tag, value)
else: else:
self.write_head(tag, TafType.INT64) self.write_head(tag, TafType.INT64)
self.buf.write(struct.pack('>q', value)) self.buf.write(struct.pack(">q", value))
def write_uint64(self, tag: int, value: int): def write_uint64(self, tag: int, value: int):
"""uint64:超过 int32 范围用 INT64""" """uint64:超过 int32 范围用 INT64"""
@@ -85,27 +87,27 @@ class TafOutputStream:
self.write_int32(tag, value) self.write_int32(tag, value)
else: else:
self.write_head(tag, TafType.INT64) self.write_head(tag, TafType.INT64)
self.buf.write(struct.pack('>Q', value)) self.buf.write(struct.pack(">Q", value))
# ---- 浮点 ---- # ---- 浮点 ----
def write_float(self, tag: int, value: float): def write_float(self, tag: int, value: float):
self.write_head(tag, TafType.FLOAT) self.write_head(tag, TafType.FLOAT)
self.buf.write(struct.pack('>f', value)) self.buf.write(struct.pack(">f", value))
def write_double(self, tag: int, value: float): def write_double(self, tag: int, value: float):
self.write_head(tag, TafType.DOUBLE) self.write_head(tag, TafType.DOUBLE)
self.buf.write(struct.pack('>d', value)) self.buf.write(struct.pack(">d", value))
# ---- 字符串 ---- # ---- 字符串 ----
def write_string(self, tag: int, value: str): def write_string(self, tag: int, value: str):
encoded = value.encode('utf-8') encoded = value.encode("utf-8")
length = len(encoded) length = len(encoded)
if length > 255: if length > 255:
self.write_head(tag, TafType.STRING4) self.write_head(tag, TafType.STRING4)
self.buf.write(struct.pack('>I', length)) self.buf.write(struct.pack(">I", length))
else: else:
self.write_head(tag, TafType.STRING1) self.write_head(tag, TafType.STRING1)
self.buf.write(struct.pack('B', length)) self.buf.write(struct.pack("B", length))
self.buf.write(encoded) self.buf.write(encoded)
# ---- 字节数组 ---- # ---- 字节数组 ----
@@ -133,8 +135,9 @@ class TafOutputStream:
self.write_struct_end() self.write_struct_end()
# ---- Map ---- # ---- Map ----
def write_map(self, tag: int, value: Dict[Any, Any], def write_map(
key_writer=None, val_writer=None): self, tag: int, value: Dict[Any, Any], key_writer=None, val_writer=None
):
self.write_head(tag, TafType.MAP) self.write_head(tag, TafType.MAP)
self.write_int32(0, len(value)) self.write_int32(0, len(value))
for k, v in value.items(): for k, v in value.items():
@@ -172,7 +175,7 @@ class TafOutputStream:
self.write_map(tag, value) self.write_map(tag, value)
elif isinstance(value, (list, tuple)): elif isinstance(value, (list, tuple)):
self.write_list(tag, list(value)) self.write_list(tag, list(value))
elif hasattr(value, 'write_to'): elif hasattr(value, "write_to"):
self.write_struct(tag, value) self.write_struct(tag, value)
else: else:
raise TypeError(f"不支持的类型: {type(value)}") raise TypeError(f"不支持的类型: {type(value)}")
@@ -182,6 +185,7 @@ class TafOutputStream:
# 输入流(解码)—— 完整实现,支持所有类型 # 输入流(解码)—— 完整实现,支持所有类型
# ============================================================ # ============================================================
class TafInputStream: class TafInputStream:
"""TAF 解码输入流""" """TAF 解码输入流"""
@@ -201,14 +205,14 @@ class TafInputStream:
data = self.buf.read(1) data = self.buf.read(1)
if not data: if not data:
raise EOFError("读取到文件末尾") raise EOFError("读取到文件末尾")
b = struct.unpack('B', data)[0] b = struct.unpack("B", data)[0]
tag = (b >> 4) & 0x0F tag = (b >> 4) & 0x0F
data_type = b & 0x0F data_type = b & 0x0F
if tag == 15: if tag == 15:
data = self.buf.read(1) data = self.buf.read(1)
if not data: if not data:
raise EOFError("读取 tag 扩展字节失败") raise EOFError("读取 tag 扩展字节失败")
tag = struct.unpack('B', data)[0] tag = struct.unpack("B", data)[0]
return tag, data_type return tag, data_type
# ---- 跳过 ---- # ---- 跳过 ----
@@ -228,10 +232,10 @@ class TafInputStream:
elif data_type == TafType.DOUBLE: elif data_type == TafType.DOUBLE:
self.buf.read(8) self.buf.read(8)
elif data_type == TafType.STRING1: elif data_type == TafType.STRING1:
length = struct.unpack('B', self.buf.read(1))[0] length = struct.unpack("B", self.buf.read(1))[0]
self.buf.read(length) self.buf.read(length)
elif data_type == TafType.STRING4: elif data_type == TafType.STRING4:
length = struct.unpack('>I', self.buf.read(4))[0] length = struct.unpack(">I", self.buf.read(4))[0]
self.buf.read(length) self.buf.read(length)
elif data_type == TafType.MAP: elif data_type == TafType.MAP:
self._skip_map() self._skip_map()
@@ -258,13 +262,13 @@ class TafInputStream:
if dtype == TafType.ZERO: if dtype == TafType.ZERO:
return 0 return 0
if dtype == TafType.INT8: if dtype == TafType.INT8:
return struct.unpack('b', self.buf.read(1))[0] return struct.unpack("b", self.buf.read(1))[0]
if dtype == TafType.INT16: if dtype == TafType.INT16:
return struct.unpack('>h', self.buf.read(2))[0] return struct.unpack(">h", self.buf.read(2))[0]
if dtype == TafType.INT32: if dtype == TafType.INT32:
return struct.unpack('>i', self.buf.read(4))[0] return struct.unpack(">i", self.buf.read(4))[0]
if dtype == TafType.INT64: if dtype == TafType.INT64:
return struct.unpack('>q', self.buf.read(8))[0] return struct.unpack(">q", self.buf.read(8))[0]
raise ValueError(f"期望整数, 实际 0x{dtype:02x}") raise ValueError(f"期望整数, 实际 0x{dtype:02x}")
def _skip_struct(self): def _skip_struct(self):
@@ -335,19 +339,23 @@ class TafInputStream:
if dtype == TafType.ZERO: if dtype == TafType.ZERO:
return 0 return 0
if dtype == TafType.INT8: if dtype == TafType.INT8:
return struct.unpack('B', self.buf.read(1))[0] return struct.unpack("B", self.buf.read(1))[0]
if dtype == TafType.INT16: if dtype == TafType.INT16:
return struct.unpack('>H', self.buf.read(2))[0] return struct.unpack(">H", self.buf.read(2))[0]
if dtype == TafType.INT32: if dtype == TafType.INT32:
return struct.unpack('>I', self.buf.read(4))[0] return struct.unpack(">I", self.buf.read(4))[0]
if dtype == TafType.INT64: if dtype == TafType.INT64:
return struct.unpack('>Q', self.buf.read(8))[0] return struct.unpack(">Q", self.buf.read(8))[0]
raise ValueError(f"期望 uint, 实际 0x{dtype:02x}") raise ValueError(f"期望 uint, 实际 0x{dtype:02x}")
def read_boolean(self, tag: int, required: bool = False, default: bool = False) -> bool: def read_boolean(
self, tag: int, required: bool = False, default: bool = False
) -> bool:
return bool(self.read_int8(tag, required, 1 if default else 0)) return bool(self.read_int8(tag, required, 1 if default else 0))
def read_float(self, tag: int, required: bool = False, default: float = 0.0) -> float: def read_float(
self, tag: int, required: bool = False, default: float = 0.0
) -> float:
found = self._find_tag(tag, required) found = self._find_tag(tag, required)
if not found: if not found:
return default return default
@@ -355,12 +363,14 @@ class TafInputStream:
if dtype == TafType.ZERO: if dtype == TafType.ZERO:
return 0.0 return 0.0
if dtype == TafType.FLOAT: if dtype == TafType.FLOAT:
return struct.unpack('>f', self.buf.read(4))[0] return struct.unpack(">f", self.buf.read(4))[0]
if dtype == TafType.DOUBLE: if dtype == TafType.DOUBLE:
return struct.unpack('>d', self.buf.read(8))[0] return struct.unpack(">d", self.buf.read(8))[0]
return float(self._read_int_value(dtype)) return float(self._read_int_value(dtype))
def read_double(self, tag: int, required: bool = False, default: float = 0.0) -> float: def read_double(
self, tag: int, required: bool = False, default: float = 0.0
) -> float:
return self.read_float(tag, required, default) return self.read_float(tag, required, default)
def read_string(self, tag: int, required: bool = False, default: str = "") -> str: def read_string(self, tag: int, required: bool = False, default: str = "") -> str:
@@ -369,14 +379,16 @@ class TafInputStream:
return default return default
dtype = found[1] dtype = found[1]
if dtype == TafType.STRING1: if dtype == TafType.STRING1:
length = struct.unpack('B', self.buf.read(1))[0] length = struct.unpack("B", self.buf.read(1))[0]
elif dtype == TafType.STRING4: elif dtype == TafType.STRING4:
length = struct.unpack('>I', self.buf.read(4))[0] length = struct.unpack(">I", self.buf.read(4))[0]
else: else:
raise ValueError(f"期望 string, 实际 0x{dtype:02x}") raise ValueError(f"期望 string, 实际 0x{dtype:02x}")
return self.buf.read(length).decode('utf-8', errors='replace') return self.buf.read(length).decode("utf-8", errors="replace")
def read_bytes(self, tag: int, required: bool = False, default: bytes = b'') -> bytes: def read_bytes(
self, tag: int, required: bool = False, default: bytes = b""
) -> bytes:
found = self._find_tag(tag, required) found = self._find_tag(tag, required)
if not found: if not found:
return default return default
@@ -388,8 +400,9 @@ class TafInputStream:
return self.buf.read(length) return self.buf.read(length)
# ---- 复合类型 ---- # ---- 复合类型 ----
def read_map(self, tag: int, required: bool = False, def read_map(
key_reader=None, val_reader=None) -> Dict: self, tag: int, required: bool = False, key_reader=None, val_reader=None
) -> Dict:
found = self._find_tag(tag, required) found = self._find_tag(tag, required)
if not found: if not found:
return {} return {}
@@ -405,8 +418,7 @@ class TafInputStream:
result[k] = v result[k] = v
return result return result
def read_list(self, tag: int, required: bool = False, def read_list(self, tag: int, required: bool = False, item_reader=None) -> List:
item_reader=None) -> List:
found = self._find_tag(tag, required) found = self._find_tag(tag, required)
if not found: if not found:
return [] return []
@@ -439,13 +451,18 @@ class TafInputStream:
return reader(self, 0) return reader(self, 0)
# 自动推断 # 自动推断
if dtype == TafType.STRING1: if dtype == TafType.STRING1:
length = struct.unpack('B', self.buf.read(1))[0] length = struct.unpack("B", self.buf.read(1))[0]
return self.buf.read(length).decode('utf-8', errors='replace') return self.buf.read(length).decode("utf-8", errors="replace")
if dtype == TafType.STRING4: if dtype == TafType.STRING4:
length = struct.unpack('>I', self.buf.read(4))[0] length = struct.unpack(">I", self.buf.read(4))[0]
return self.buf.read(length).decode('utf-8', errors='replace') return self.buf.read(length).decode("utf-8", errors="replace")
if dtype in (TafType.ZERO, TafType.INT8, TafType.INT16, if dtype in (
TafType.INT32, TafType.INT64): TafType.ZERO,
TafType.INT8,
TafType.INT16,
TafType.INT32,
TafType.INT64,
):
return self._read_int_value(dtype) return self._read_int_value(dtype)
if dtype == TafType.STRUCT_BEGIN: if dtype == TafType.STRUCT_BEGIN:
# 未知 struct,跳过 # 未知 struct,跳过
@@ -459,6 +476,7 @@ class TafInputStream:
# 结构体基类 # 结构体基类
# ============================================================ # ============================================================
class TafStruct: class TafStruct:
"""TAF 结构体基类:子类实现 write_to / read_from""" """TAF 结构体基类:子类实现 write_to / read_from"""
@@ -470,8 +488,7 @@ class TafStruct:
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""调试用:转字典""" """调试用:转字典"""
return {k: v for k, v in self.__dict__.items() return {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
if not k.startswith('_')}
def __repr__(self): def __repr__(self):
return f"{self.__class__.__name__}({self.to_dict()})" return f"{self.__class__.__name__}({self.to_dict()})"
+1
View File
@@ -2,6 +2,7 @@
基于 AES-128-ECB 与 0 填充。 基于 AES-128-ECB 与 0 填充。
""" """
from __future__ import annotations from __future__ import annotations
from Crypto.Cipher import AES from Crypto.Cipher import AES
+37 -13
View File
@@ -134,12 +134,18 @@ def make_user_action(now_ms: int | None = None) -> str:
"longitude": "-1.0", "longitude": "-1.0",
"ssid": "", "ssid": "",
"user_action": [ "user_action": [
{"id": "24", "time": str(t1), {
"x": str(random.randint(150, 900)), "id": "24",
"y": str(random.randint(800, 1600))}, "time": str(t1),
{"id": "11", "time": str(t2), "x": str(random.randint(150, 900)),
"x": str(random.randint(150, 900)), "y": str(random.randint(800, 1600)),
"y": str(random.randint(800, 1600))}, },
{
"id": "11",
"time": str(t2),
"x": str(random.randint(150, 900)),
"y": str(random.randint(800, 1600)),
},
], ],
}, },
ensure_ascii=False, ensure_ascii=False,
@@ -152,10 +158,17 @@ def make_trace_id(pid: int = 0) -> str:
return f"{random.getrandbits(64):016x}-{pid}-{int(_time.time() * 1000)}" return f"{random.getrandbits(64):016x}-{pid}-{int(_time.time() * 1000)}"
def _build_wup_data(w: _Writer, uid_str: str, sha1_password: str, def _build_wup_data(
safedeviceid: str, hdid: str, session: int, w: _Writer,
trace_id: str, user_action_json: str, uid_str: str,
device_info: Dict[str, str]) -> None: sha1_password: str,
safedeviceid: str,
hdid: str,
session: int,
trace_id: str,
user_action_json: str,
device_info: Dict[str, str],
) -> None:
"""编码 _wup_data struct。""" """编码 _wup_data struct。"""
meta_json = _build_meta_json(session, trace_id) meta_json = _build_meta_json(session, trace_id)
name = _make_name(uid_str) name = _make_name(uid_str)
@@ -179,7 +192,9 @@ def _build_wup_data(w: _Writer, uid_str: str, sha1_password: str,
# -- t1: 设备信息 struct -- # -- t1: 设备信息 struct --
di = device_info di = device_info
w.struct_begin(1) w.struct_begin(1)
w.string(0, hdid) # t1.t0 = HDID32 (登录帧32hex设备证书, libhydeviceid.so 硬锚, 换→APP_SIGN_NOT_MATCH) w.string(
0, hdid
) # t1.t0 = HDID32 (登录帧32hex设备证书, libhydeviceid.so 硬锚, 换→APP_SIGN_NOT_MATCH)
w.string(1, di.get("app_version", "13.4.22")) w.string(1, di.get("app_version", "13.4.22"))
w.string(2, di.get("sdk_version", "1.0.80138")) w.string(2, di.get("sdk_version", "1.0.80138"))
w.string(3, "") w.string(3, "")
@@ -224,8 +239,17 @@ def build_password_login_wup(
) -> bytes: ) -> bytes:
"""构造密码登录的 WUP TAF 请求体。""" """构造密码登录的 WUP TAF 请求体。"""
wd = _Writer() wd = _Writer()
_build_wup_data(wd, uid_str, sha1_password, safedeviceid, hdid, _build_wup_data(
session, trace_id, user_action_json, device_info) wd,
uid_str,
sha1_password,
safedeviceid,
hdid,
session,
trace_id,
user_action_json,
device_info,
)
wup_data = wd.get() wup_data = wd.get()
req = _Writer() req = _Writer()
+26 -20
View File
@@ -9,6 +9,7 @@ Wup 包结构:
tag7:sBuffer(bytes), tag8:iTimeout, tag9:context(map), tag10:status(map) tag7:sBuffer(bytes), tag8:iTimeout, tag9:context(map), tag10:status(map)
sBuffer = Map<"tReq", 编码后的请求结构体> sBuffer = Map<"tReq", 编码后的请求结构体>
""" """
import struct import struct
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct
@@ -18,16 +19,16 @@ class WupRequest:
"""Wup 请求对象""" """Wup 请求对象"""
def __init__(self): def __init__(self):
self.iVersion: int = 3 # tag 1 self.iVersion: int = 3 # tag 1
self.cPacketType: int = 0 # tag 2 self.cPacketType: int = 0 # tag 2
self.iMessageType: int = 0 # tag 3 self.iMessageType: int = 0 # tag 3
self.iRequestId: int = 0 # tag 4 self.iRequestId: int = 0 # tag 4
self.sServantName: str = "" # tag 5 self.sServantName: str = "" # tag 5
self.sFuncName: str = "" # tag 6 self.sFuncName: str = "" # tag 6
self.sBuffer: bytes = b'' # tag 7 self.sBuffer: bytes = b"" # tag 7
self.iTimeout: int = 3000 # tag 8 self.iTimeout: int = 3000 # tag 8
self.context: Dict[str, str] = {} # tag 9 self.context: Dict[str, str] = {} # tag 9
self.status: Dict[str, str] = {} # tag 10 self.status: Dict[str, str] = {} # tag 10
self.newdata: Dict[str, bytes] = {} self.newdata: Dict[str, bytes] = {}
def setServant(self, name: str): def setServant(self, name: str):
@@ -49,7 +50,7 @@ class WupRequest:
""" """
os = TafOutputStream() os = TafOutputStream()
if isinstance(struct_data, TafStruct) or hasattr(struct_data, 'write_to'): if isinstance(struct_data, TafStruct) or hasattr(struct_data, "write_to"):
# 结构体对象:STRUCT_BEGIN + 内容 + STRUCT_END # 结构体对象:STRUCT_BEGIN + 内容 + STRUCT_END
os.write_struct(0, struct_data) os.write_struct(0, struct_data)
elif isinstance(struct_data, dict): elif isinstance(struct_data, dict):
@@ -83,7 +84,7 @@ class WupRequest:
os.write_map(tag, value) os.write_map(tag, value)
elif isinstance(value, (list, tuple)): elif isinstance(value, (list, tuple)):
os.write_list(tag, list(value)) os.write_list(tag, list(value))
elif hasattr(value, 'write_to'): elif hasattr(value, "write_to"):
os.write_struct(tag, value) os.write_struct(tag, value)
else: else:
raise TypeError(f"不支持的字段类型: {type(value)}") raise TypeError(f"不支持的字段类型: {type(value)}")
@@ -115,14 +116,14 @@ class WupRequest:
# 3. 长度前缀 # 3. 长度前缀
length = 4 + len(wup_body) length = 4 + len(wup_body)
return struct.pack('>I', length) + wup_body return struct.pack(">I", length) + wup_body
def normalize_wup_payload(data: bytes) -> bytes: def normalize_wup_payload(data: bytes) -> bytes:
"""去掉可选的 4 字节 WUP 长度前缀,返回裸 WUP body""" """去掉可选的 4 字节 WUP 长度前缀,返回裸 WUP body"""
if len(data) < 4: if len(data) < 4:
return data return data
declared_len = struct.unpack('>I', data[0:4])[0] declared_len = struct.unpack(">I", data[0:4])[0]
if declared_len == len(data) or declared_len + 4 == len(data): if declared_len == len(data) or declared_len + 4 == len(data):
return data[4:] return data[4:]
return data return data
@@ -138,7 +139,7 @@ class WupResponse:
self.iRequestId: int = 0 self.iRequestId: int = 0
self.sServantName: str = "" self.sServantName: str = ""
self.sFuncName: str = "" self.sFuncName: str = ""
self.sBuffer: bytes = b'' self.sBuffer: bytes = b""
self.iTimeout: int = 0 self.iTimeout: int = 0
self.context: Dict[str, str] = {} self.context: Dict[str, str] = {}
self.status: Dict[str, str] = {} self.status: Dict[str, str] = {}
@@ -249,19 +250,20 @@ class WupResponse:
# 辅助:按已知 dtype 读取值 # 辅助:按已知 dtype 读取值
# ============================================================ # ============================================================
def _read_string_value(ins: TafInputStream, dtype: int) -> str: def _read_string_value(ins: TafInputStream, dtype: int) -> str:
if dtype == TafType.STRING1: if dtype == TafType.STRING1:
length = struct.unpack('B', ins.buf.read(1))[0] length = struct.unpack("B", ins.buf.read(1))[0]
elif dtype == TafType.STRING4: elif dtype == TafType.STRING4:
length = struct.unpack('>I', ins.buf.read(4))[0] length = struct.unpack(">I", ins.buf.read(4))[0]
else: else:
return "" return ""
return ins.buf.read(length).decode('utf-8', errors='replace') return ins.buf.read(length).decode("utf-8", errors="replace")
def _read_bytes_value(ins: TafInputStream, dtype: int) -> bytes: def _read_bytes_value(ins: TafInputStream, dtype: int) -> bytes:
if dtype != TafType.SIMPLE_LIST: if dtype != TafType.SIMPLE_LIST:
return b'' return b""
ins.read_head() # 元素类型 INT8 ins.read_head() # 元素类型 INT8
length = ins._read_int_len() length = ins._read_int_len()
return ins.buf.read(length) return ins.buf.read(length)
@@ -276,6 +278,10 @@ def _read_map_value(ins: TafInputStream, dtype: int) -> Dict:
_, kt = ins.read_head() _, kt = ins.read_head()
k = _read_string_value(ins, kt) k = _read_string_value(ins, kt)
_, vt = ins.read_head() _, vt = ins.read_head()
v = _read_string_value(ins, vt) if vt in (TafType.STRING1, TafType.STRING4) else "" v = (
_read_string_value(ins, vt)
if vt in (TafType.STRING1, TafType.STRING4)
else ""
)
result[k] = v result[k] = v
return result return result
+35 -15
View File
@@ -58,12 +58,14 @@ def parse_sms_lines(text: str) -> list[SmsLine]:
continue continue
if not phone or not url: if not phone or not url:
continue continue
rows.append(SmsLine( rows.append(
phone=phone, SmsLine(
url=url, phone=phone,
provider=detect_provider(url), url=url,
raw=line, provider=detect_provider(url),
)) raw=line,
)
)
return rows return rows
@@ -133,32 +135,50 @@ class SmsProviderClient:
if low.startswith("yes|"): if low.startswith("yes|"):
code = extract_sms_code(body) code = extract_sms_code(body)
if code: if code:
return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) return SmsPollResult(
return SmsPollResult(status="error", message=f"收到短信但未识别验证码: {body}", raw=body) status="code", code=code, message="收到验证码", raw=body
)
return SmsPollResult(
status="error", message=f"已收到短信但未识别验证码: {body}", raw=body
)
code = extract_sms_code(body) code = extract_sms_code(body)
if code: if code:
return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) return SmsPollResult(
status="code", code=code, message="收到验证码", raw=body
)
return SmsPollResult(status="waiting", message=body, raw=body) return SmsPollResult(status="waiting", message=body, raw=body)
def _parse_sms8(self, body: str, after_time: datetime | None = None) -> SmsPollResult: def _parse_sms8(
self, body: str, after_time: datetime | None = None
) -> SmsPollResult:
try: try:
payload = json.loads(body) payload = json.loads(body)
except json.JSONDecodeError: except json.JSONDecodeError:
code = extract_sms_code(body) code = extract_sms_code(body)
if code: if code:
return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) return SmsPollResult(
return SmsPollResult(status="error", message=f"短信平台返回非 JSON: {body[:120]}", raw=body) status="code", code=code, message="收到验证码", raw=body
)
return SmsPollResult(
status="error", message=f"短信平台返回非 JSON: {body[:120]}", raw=body
)
data = payload.get("data") if isinstance(payload.get("data"), dict) else {} data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
msg = str(payload.get("msg") or "") msg = str(payload.get("msg") or "")
code_text = str(data.get("code") or "") code_text = str(data.get("code") or "")
code_time = _parse_sms8_time(str(data.get("code_time") or "")) code_time = _parse_sms8_time(str(data.get("code_time") or ""))
if after_time and code_time and code_time < after_time.replace(tzinfo=None): if after_time and code_time and code_time < after_time.replace(tzinfo=None):
return SmsPollResult(status="waiting", message="验证码时间早于本次发码", raw=body) return SmsPollResult(
status="waiting", message="验证码时间早于本次发码", raw=body
)
code = extract_sms_code(code_text) code = extract_sms_code(code_text)
if int(payload.get("code") or 0) == 1 and code: if int(payload.get("code") or 0) == 1 and code:
return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) return SmsPollResult(
status="code", code=code, message="收到验证码", raw=body
)
if code: if code:
return SmsPollResult(status="code", code=code, message="收到验证码", raw=body) return SmsPollResult(
status="code", code=code, message="收到验证码", raw=body
)
return SmsPollResult(status="waiting", message=msg or "暂无验证码", raw=body) return SmsPollResult(status="waiting", message=msg or "暂无验证码", raw=body)
+6 -3
View File
@@ -12,11 +12,14 @@
```python ```python
import frida, time import frida, time
d = frida.get_device_manager().add_remote_device("127.0.0.1:31878") # frida-server -l 0.0.0.0:31878 d = frida.get_device_manager().add_remote_device(
"127.0.0.1:31878"
) # frida-server -l 0.0.0.0:31878
pid = d.spawn(["com.duowan.kiwi"]) pid = d.spawn(["com.duowan.kiwi"])
s = d.attach(pid) s = d.attach(pid)
from bypass_loader import load_bypass # scripts/ 在 sys.path (同目录运行即可) from bypass_loader import load_bypass # scripts/ 在 sys.path (同目录运行即可)
load_bypass(s) # ← 唯一的绕过步骤, 可传参
load_bypass(s) # ← 唯一的绕过步骤, 可传参
d.resume(pid) d.resume(pid)
# 之后挂业务钩子 (挂业务 JS 的时机不再敏感 — patch_guard 已内置延迟) # 之后挂业务钩子 (挂业务 JS 的时机不再敏感 — patch_guard 已内置延迟)
+32 -12
View File
@@ -73,10 +73,14 @@ def _normalize_value(value: object, target_column) -> object:
try: try:
return json.loads(value) return json.loads(value)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise ValueError(f"{target_column.name} 存在非法 JSON{value[:120]!r}") from exc raise ValueError(
f"{target_column.name} 存在非法 JSON{value[:120]!r}"
) from exc
def _source_rows(source_connection: Connection, source_table: Table, target_table: Table) -> Iterable[dict]: def _source_rows(
source_connection: Connection, source_table: Table, target_table: Table
) -> Iterable[dict]:
columns = [column.name for column in target_table.columns] columns = [column.name for column in target_table.columns]
missing_columns = [column for column in columns if column not in source_table.c] missing_columns = [column for column in columns if column not in source_table.c]
if missing_columns: if missing_columns:
@@ -88,7 +92,9 @@ def _source_rows(source_connection: Connection, source_table: Table, target_tabl
primary_key_columns = list(source_table.primary_key.columns) primary_key_columns = list(source_table.primary_key.columns)
statement = select(*(source_table.c[name] for name in columns)) statement = select(*(source_table.c[name] for name in columns))
if primary_key_columns: if primary_key_columns:
statement = statement.order_by(*(column.asc() for column in primary_key_columns)) statement = statement.order_by(
*(column.asc() for column in primary_key_columns)
)
for row in source_connection.execute(statement).mappings(): for row in source_connection.execute(statement).mappings():
yield { yield {
@@ -101,12 +107,12 @@ def _ensure_target_is_empty(target_engine: Engine, target_tables: list[Table]) -
"""拒绝向已有业务数据的 MySQL 写入,防止误覆盖。""" """拒绝向已有业务数据的 MySQL 写入,防止误覆盖。"""
with target_engine.connect() as connection: with target_engine.connect() as connection:
occupied = [ occupied = [
table.name table.name for table in target_tables if _count_rows(connection, table) > 0
for table in target_tables
if _count_rows(connection, table) > 0
] ]
if occupied: if occupied:
raise RuntimeError(f"目标 MySQL 已存在业务数据,拒绝迁移:{', '.join(occupied)}") raise RuntimeError(
f"目标 MySQL 已存在业务数据,拒绝迁移:{', '.join(occupied)}"
)
def _upgrade_source_sqlite(source_path: Path) -> None: def _upgrade_source_sqlite(source_path: Path) -> None:
@@ -115,7 +121,11 @@ def _upgrade_source_sqlite(source_path: Path) -> None:
source_env = os.environ.copy() source_env = os.environ.copy()
source_env["DATABASE_URL"] = f"sqlite:///{source_path}" source_env["DATABASE_URL"] = f"sqlite:///{source_path}"
subprocess.run( subprocess.run(
[sys.executable, "-c", "from web.backend.database import run_migrations; run_migrations()"], [
sys.executable,
"-c",
"from web.backend.database import run_migrations; run_migrations()",
],
cwd=PROJECT_ROOT, cwd=PROJECT_ROOT,
env=source_env, env=source_env,
check=True, check=True,
@@ -141,7 +151,9 @@ def main() -> int:
target_url = target_url or DATABASE_URL target_url = target_url or DATABASE_URL
if not target_url.startswith("mysql+"): if not target_url.startswith("mysql+"):
raise ValueError("目标库必须是 MySQL;可传 --target-url,或设置 DB_HOST/DB_* 环境变量") raise ValueError(
"目标库必须是 MySQL;可传 --target-url,或设置 DB_HOST/DB_* 环境变量"
)
print("正在初始化目标 MySQL 表结构...") print("正在初始化目标 MySQL 表结构...")
run_migrations() run_migrations()
@@ -153,7 +165,9 @@ def main() -> int:
try: try:
source_table_names = set(inspect(source_engine).get_table_names()) source_table_names = set(inspect(source_engine).get_table_names())
unexpected_tables = source_table_names - target_table_names - IGNORED_SOURCE_TABLES unexpected_tables = (
source_table_names - target_table_names - IGNORED_SOURCE_TABLES
)
if unexpected_tables: if unexpected_tables:
raise RuntimeError( raise RuntimeError(
"源 SQLite 存在当前程序无法识别的表:" "源 SQLite 存在当前程序无法识别的表:"
@@ -171,7 +185,10 @@ def main() -> int:
_ensure_target_is_empty(target_engine, tables_to_copy) _ensure_target_is_empty(target_engine, tables_to_copy)
with source_engine.connect() as source_connection, target_engine.begin() as target_connection: with (
source_engine.connect() as source_connection,
target_engine.begin() as target_connection,
):
for target_table in tables_to_copy: for target_table in tables_to_copy:
source_table = source_metadata.tables[target_table.name] source_table = source_metadata.tables[target_table.name]
source_count = _count_rows(source_connection, source_table) source_count = _count_rows(source_connection, source_table)
@@ -184,7 +201,10 @@ def main() -> int:
for batch in _chunks(rows, args.batch_size): for batch in _chunks(rows, args.batch_size):
target_connection.execute(target_table.insert(), batch) target_connection.execute(target_table.insert(), batch)
with source_engine.connect() as source_connection, target_engine.connect() as target_connection: with (
source_engine.connect() as source_connection,
target_engine.connect() as target_connection,
):
for target_table in tables_to_copy: for target_table in tables_to_copy:
source_table = source_metadata.tables[target_table.name] source_table = source_metadata.tables[target_table.name]
source_count = _count_rows(source_connection, source_table) source_count = _count_rows(source_connection, source_table)
+220 -71
View File
@@ -17,6 +17,7 @@
python3 main.py sample-session # 用迁移的 live 捕获生成示例会话态 python3 main.py sample-session # 用迁移的 live 捕获生成示例会话态
python3 main.py sample-order # 用迁移的 order7 生成示例订单 python3 main.py sample-order # 用迁移的 order7 生成示例订单
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@@ -50,14 +51,31 @@ from pyvm.protocol import ( # noqa: E402
REPLAY = ROOT / "replay" REPLAY = ROOT / "replay"
DEFAULT_APPID = PAY_APPID DEFAULT_APPID = PAY_APPID
DEFAULT_SAVE_URL = f"https://api.unipay.qq.com/v1/r/{DEFAULT_APPID}/web_save" DEFAULT_SAVE_URL = f"https://api.unipay.qq.com/v1/r/{DEFAULT_APPID}/web_save"
ORDER_FIELDS = ["token_id", "openid", "openkey", "session_id", "session_type", "zoneid", ORDER_FIELDS = [
"pay_method", "buy_quantity", "mb_pwd", "pay_id", "auth_key", "token_id",
"card_value", "accounttype", "provide_uin", "extend", "ts", "openid",
"from_h5", "webversion"] "openkey",
"session_id",
"session_type",
"zoneid",
"pay_method",
"buy_quantity",
"mb_pwd",
"pay_id",
"auth_key",
"card_value",
"accounttype",
"provide_uin",
"extend",
"ts",
"from_h5",
"webversion",
]
# ---------------------------------------------------------------- 工具 # ---------------------------------------------------------------- 工具
def _write_private_text(path: Path, content: str) -> None: def _write_private_text(path: Path, content: str) -> None:
"""写入任务协议证据并限制为当前用户可读。""" """写入任务协议证据并限制为当前用户可读。"""
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
@@ -65,6 +83,7 @@ def _write_private_text(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8") path.write_text(content, encoding="utf-8")
os.chmod(path, 0o600) os.chmod(path, 0o600)
def _load_cap(path: Path) -> dict: def _load_cap(path: Path) -> dict:
"""加载 deepCap 捕获 JSON,兼容三种存档格式,返回顶层 dict(含 C 键)。 """加载 deepCap 捕获 JSON,兼容三种存档格式,返回顶层 dict(含 C 键)。
@@ -113,32 +132,47 @@ def parse_plaintext(path: str | Path) -> dict:
# ---------------------------------------------------------------- 命令 # ---------------------------------------------------------------- 命令
def cmd_verify(args=None) -> int: def cmd_verify(args=None) -> int:
"""校验当前受版本控制的 goods 协议材料,不依赖已删除的历史抓包。""" """校验当前受版本控制的 goods 协议材料,不依赖已删除的历史抓包。"""
args_path = REPLAY / "live" / "default" / "args-template.json" args_path = REPLAY / "live" / "default" / "args-template.json"
args_template = json.loads(args_path.read_text(encoding="utf-8")) args_template = json.loads(args_path.read_text(encoding="utf-8"))
validate_goods_materials(args_template) validate_goods_materials(args_template)
required = ("bytecode.json", "constants.json", "xmidasops.json", "e2e/multi-1.json", required = (
"e2e/ws-args.json", "deepcaps2/cap-69667.json") "bytecode.json",
"constants.json",
"xmidasops.json",
"e2e/multi-1.json",
"e2e/ws-args.json",
"deepcaps2/cap-69667.json",
)
missing = [name for name in required if not (REPLAY / name).exists()] missing = [name for name in required if not (REPLAY / name).exists()]
if missing: if missing:
print(f"❌ goods 协议材料缺失: {', '.join(missing)}") print(f"❌ goods 协议材料缺失: {', '.join(missing)}")
return 1 return 1
print("✅ goods 协议材料校验通过(18 槽模板、Te/S-box、VM 文件均完整)") print("✅ goods 协议材料校验通过(18 槽模板、Te/S-box、VM 文件均完整)")
print(" 历史逐字节黄金向量未随仓库保留;如需恢复,可放入 replay/golden 后由 CI 自动执行。") print(
" 历史逐字节黄金向量未随仓库保留;如需恢复,可放入 replay/golden 后由 CI 自动执行。"
)
return 0 return 0
def _gen_with_session(st: SessionState, order: dict) -> str: def _gen_with_session(st: SessionState, order: dict) -> str:
"""用会话态 + 订单参数生成 encrypt_msg(args_template 会话绑定,必须用捕获值)。""" """用会话态 + 订单参数生成 encrypt_msg(args_template 会话绑定,必须用捕获值)。"""
from pyvm.algorithm import decode_d from pyvm.algorithm import decode_d
params = {k: order.get(k, "") for k in ORDER_FIELDS} params = {k: order.get(k, "") for k in ORDER_FIELDS}
args_tpl = decode_d(st.args_template_d) args_tpl = decode_d(st.args_template_d)
return generate_encrypt_msg_offline( return generate_encrypt_msg_offline(
params, order.get("fk_extend", ""), order.get("ts", ""), order.get("_rand", ""), params,
xmidas=st.xmidas_ops, xmidas_token=st.xmidas_token, order.get("fk_extend", ""),
order.get("ts", ""),
order.get("_rand", ""),
xmidas=st.xmidas_ops,
xmidas_token=st.xmidas_token,
args_template=args_tpl, args_template=args_tpl,
key16=st.key16, key1=st.key1, key16=st.key16,
key1=st.key1,
) )
@@ -186,7 +220,8 @@ def cmd_submit(args) -> int:
url = args.url or DEFAULT_SAVE_URL url = args.url or DEFAULT_SAVE_URL
cookie_str = "; ".join(f"{k}={v}" for k, v in st.cookies.items()) cookie_str = "; ".join(f"{k}={v}" for k, v in st.cookies.items())
req = urllib.request.Request( req = urllib.request.Request(
url, data=body.encode("utf-8"), url,
data=body.encode("utf-8"),
headers={ headers={
"User-Agent": GOODS_USER_AGENT, "User-Agent": GOODS_USER_AGENT,
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
@@ -221,6 +256,7 @@ def cmd_submit(args) -> int:
def cmd_sample_session(args) -> int: def cmd_sample_session(args) -> int:
"""用迁移的 live 捕获生成示例会话态(key16/key1/xmidas_ops)。""" """用迁移的 live 捕获生成示例会话态(key16/key1/xmidas_ops)。"""
from pyvm.algorithm import decode_d from pyvm.algorithm import decode_d
web_args = decode_d(_load_cap(REPLAY / "live/caps7/cap-85091.json")["C"][2]) web_args = decode_d(_load_cap(REPLAY / "live/caps7/cap-85091.json")["C"][2])
cap7 = _load_cap(REPLAY / "live/caps7/cap-85091.json") cap7 = _load_cap(REPLAY / "live/caps7/cap-85091.json")
st = SessionState( st = SessionState(
@@ -228,7 +264,9 @@ def cmd_sample_session(args) -> int:
key16=list(web_args[6][0]), key16=list(web_args[6][0]),
key1=list(web_args[0][0]), key1=list(web_args[0][0]),
xmidas_token="DE46DBA4754D42A6B66ADD4319FF80C144D9ED22ED73384C271793D9A9AA2FFBD6C104BE8D4A7F4ED2A8688FCB6F7540", xmidas_token="DE46DBA4754D42A6B66ADD4319FF80C144D9ED22ED73384C271793D9A9AA2FFBD6C104BE8D4A7F4ED2A8688FCB6F7540",
args_template_d=cap7["C"][2] if isinstance(cap7["C"][2], str) else json.dumps(cap7["C"][2], ensure_ascii=False), args_template_d=cap7["C"][2]
if isinstance(cap7["C"][2], str)
else json.dumps(cap7["C"][2], ensure_ascii=False),
cookies={}, cookies={},
source="live/order7 (归档示例,cookies 为空)", source="live/order7 (归档示例,cookies 为空)",
) )
@@ -252,6 +290,7 @@ def cmd_sample_order(args) -> int:
# ---------------------------------------------------------------- mall 命令 # ---------------------------------------------------------------- mall 命令
def cmd_mall_verify(args=None) -> int: def cmd_mall_verify(args=None) -> int:
"""校验当前受版本控制的 mall VM 和固定槽模板。""" """校验当前受版本控制的 mall VM 和固定槽模板。"""
fixed_path = REPLAY / "mall" / "transform-fixed.json" fixed_path = REPLAY / "mall" / "transform-fixed.json"
@@ -282,7 +321,9 @@ def cmd_mall_gen(args) -> int:
def cmd_mall_sample(args) -> int: def cmd_mall_sample(args) -> int:
"""用归档同会话黄金对生成示例 mall 会话态(config/mall-session.json)。""" """用归档同会话黄金对生成示例 mall 会话态(config/mall-session.json)。"""
g = json.loads((ROOT / "config/golden/golden-final.json").read_text(encoding="utf-8")) g = json.loads(
(ROOT / "config/golden/golden-final.json").read_text(encoding="utf-8")
)
session = MallSession(g["transform_input"], g["xmidas_ops"]) session = MallSession(g["transform_input"], g["xmidas_ops"])
out = Path(args.output) if args.output else ROOT / "config" / "mall-session.json" out = Path(args.output) if args.output else ROOT / "config" / "mall-session.json"
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
@@ -325,7 +366,8 @@ def cmd_mall_submit(args) -> int:
if "midas_openkey" not in cookies and "accesstoken" in cookies: if "midas_openkey" not in cookies and "accesstoken" in cookies:
cookie_str += "; midas_openkey=" + cookies["accesstoken"] cookie_str += "; midas_openkey=" + cookies["accesstoken"]
req = urllib.request.Request( req = urllib.request.Request(
url, data=new_body.encode("utf-8"), url,
data=new_body.encode("utf-8"),
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json, text/plain, */*", "Accept": "application/json, text/plain, */*",
@@ -346,7 +388,11 @@ def cmd_mall_submit(args) -> int:
print(f"[submit] 网络错误: {e!r}") print(f"[submit] 网络错误: {e!r}")
return 3 return 3
print(f"[submit] 响应: {raw[:800]}") print(f"[submit] 响应: {raw[:800]}")
out = Path(args.output) if args.output else ROOT / "config" / "mall-order-response.json" out = (
Path(args.output)
if args.output
else ROOT / "config" / "mall-order-response.json"
)
js = None js = None
ret = None ret = None
try: try:
@@ -370,7 +416,9 @@ def cmd_mall_submit(args) -> int:
print(f" 失败响应 → {fail_out}(保留上次成功响应)") print(f" 失败响应 → {fail_out}(保留上次成功响应)")
if ret in ("1018", 1018): if ret in ("1018", 1018):
print(" 原因: mall 登录态失效——请重新在浏览器登录并采集 mall 会话态") print(" 原因: mall 登录态失效——请重新在浏览器登录并采集 mall 会话态")
print(" (node scripts/capture-mall-data.mjs,或手动刷新 mall-session.json 的 cookies)") print(
" (node scripts/capture-mall-data.mjs,或手动刷新 mall-session.json 的 cookies)"
)
return 1 return 1
@@ -383,11 +431,15 @@ def cmd_mall_capture(args) -> int:
def main() -> int: def main() -> int:
ap = argparse.ArgumentParser(description="YYB 加密参数生成框架(goods web_save + mall PlaceOrder)") ap = argparse.ArgumentParser(
description="YYB 加密参数生成框架(goods web_save + mall PlaceOrder)"
)
sub = ap.add_subparsers(dest="module", required=True) sub = ap.add_subparsers(dest="module", required=True)
# ---- goods 组 ---- # ---- goods 组 ----
pg = sub.add_parser("goods", help="goods 侧(web_save / web_new_encrypt,CHAOS VM 116 opcode)") pg = sub.add_parser(
"goods", help="goods 侧(web_save / web_new_encrypt,CHAOS VM 116 opcode)"
)
gsub = pg.add_subparsers(dest="cmd", required=True) gsub = pg.add_subparsers(dest="cmd", required=True)
gsub.add_parser("verify", help="校验当前 goods 协议材料") gsub.add_parser("verify", help="校验当前 goods 协议材料")
p = gsub.add_parser("gen", help="仅生成 encrypt_msg(不联网)") p = gsub.add_parser("gen", help="仅生成 encrypt_msg(不联网)")
@@ -416,30 +468,64 @@ def main() -> int:
p.add_argument("--frames", default=None, help="最新捕获的 frames.jsonl(优先)") p.add_argument("--frames", default=None, help="最新捕获的 frames.jsonl(优先)")
p.add_argument("--output", default=None) p.add_argument("--output", default=None)
p = msub.add_parser("submit", help="生成 encrypt_msg + 提交 PlaceOrder(纯 Python)") p = msub.add_parser("submit", help="生成 encrypt_msg + 提交 PlaceOrder(纯 Python)")
p.add_argument("--session", required=True, help="mall-session.json(含 transform_input/xmidas/cookies)") p.add_argument(
p.add_argument("--order-template", default=str(ROOT / "config" / "mall-order-template.json")) "--session",
required=True,
help="mall-session.json(含 transform_input/xmidas/cookies)",
)
p.add_argument(
"--order-template", default=str(ROOT / "config" / "mall-order-template.json")
)
p.add_argument("--output", default=None) p.add_argument("--output", default=None)
p = msub.add_parser("auto", help="仅凭 CK 全自动下单(纯HTTP GetPayToken + 纯Python encrypt_msg,无浏览器采集)") p = msub.add_parser(
"auto",
help="仅凭 CK 全自动下单(纯HTTP GetPayToken + 纯Python encrypt_msg,无浏览器采集)",
)
p.add_argument("--session", default=str(ROOT / "config" / "mall-session.json")) p.add_argument("--session", default=str(ROOT / "config" / "mall-session.json"))
p.add_argument("--order-template", default=str(ROOT / "config" / "mall-order-template.json")) p.add_argument(
p.add_argument("--product-id", default=None, help="和平精英点券商品 ID;默认使用模板当前商品") "--order-template", default=str(ROOT / "config" / "mall-order-template.json")
)
p.add_argument(
"--product-id", default=None, help="和平精英点券商品 ID;默认使用模板当前商品"
)
p.add_argument("--offer-id", default=None, help="当前商品服务端 offer ID") p.add_argument("--offer-id", default=None, help="当前商品服务端 offer ID")
p.add_argument("--quantity", type=int, default=None, help="当前点券商品购买份数(正整数)") p.add_argument(
"--quantity", type=int, default=None, help="当前点券商品购买份数(正整数)"
)
p.add_argument("--role-id", default=None, help="游戏角色 ID;默认使用模板当前角色") p.add_argument("--role-id", default=None, help="游戏角色 ID;默认使用模板当前角色")
p.add_argument("--role-name", default=None, help="游戏角色名称;默认使用模板当前角色") p.add_argument(
"--role-name", default=None, help="游戏角色名称;默认使用模板当前角色"
)
p.add_argument("--zone-id", default=None, help="游戏区服 ID;默认使用模板当前区服") p.add_argument("--zone-id", default=None, help="游戏区服 ID;默认使用模板当前区服")
p.add_argument("--zone-name", default=None, help="游戏区服名称") p.add_argument("--zone-name", default=None, help="游戏区服名称")
p.add_argument("--area", default=None, help="游戏大区 ID(QQ 平台与 zoneid 不同,来自角色查询的 partition_info") p.add_argument(
"--area",
default=None,
help="游戏大区 ID(QQ 平台与 zoneid 不同,来自角色查询的 partition_info",
)
p.add_argument("--partition", default=None, help="游戏分区 IDQQ 平台可为空)") p.add_argument("--partition", default=None, help="游戏分区 IDQQ 平台可为空)")
p.add_argument("--pf", default=None, help="支付平台标识;由角色选择器按 Android/iOS 写入") p.add_argument(
"--pf", default=None, help="支付平台标识;由角色选择器按 Android/iOS 写入"
)
p.add_argument("--output", default=None) p.add_argument("--output", default=None)
p = msub.add_parser("pay", help="mall 下单 -> goods web_save -> 微信支付二维码(纯 Python)") p = msub.add_parser(
p.add_argument("--mall-response", default=str(ROOT / "config" / "mall-order-response.json"), "pay", help="mall 下单 -> goods web_save -> 微信支付二维码(纯 Python)"
help="PlaceOrder 响应(含 url_params/token)") )
p.add_argument("--goods-dir", default=str(ROOT / "replay" / "live" / "order10"), p.add_argument(
help="goods 会话态目录(plaintext/body/xmidasops/keys/cap/web-token)") "--mall-response",
p.add_argument("--session", default=str(ROOT / "config" / "mall-session.json"), default=str(ROOT / "config" / "mall-order-response.json"),
help="mall 登录态(含 cookies)") help="PlaceOrder 响应(含 url_params/token)",
)
p.add_argument(
"--goods-dir",
default=str(ROOT / "replay" / "live" / "order10"),
help="goods 会话态目录(plaintext/body/xmidasops/keys/cap/web-token)",
)
p.add_argument(
"--session",
default=str(ROOT / "config" / "mall-session.json"),
help="mall 登录态(含 cookies)",
)
p.add_argument("--appid", default=DEFAULT_APPID) p.add_argument("--appid", default=DEFAULT_APPID)
p.add_argument("--output", default=None, help="二维码 PNG 输出路径") p.add_argument("--output", default=None, help="二维码 PNG 输出路径")
msub.add_parser("capture", help="用浏览器捕获 mall 会话态(Node 脚本)") msub.add_parser("capture", help="用浏览器捕获 mall 会话态(Node 脚本)")
@@ -447,20 +533,29 @@ def main() -> int:
args = ap.parse_args() args = ap.parse_args()
try: try:
if args.module == "goods": if args.module == "goods":
return {"verify": cmd_verify, "gen": cmd_gen, "submit": cmd_submit, return {
"sample-session": cmd_sample_session, "sample-order": cmd_sample_order}[args.cmd](args) "verify": cmd_verify,
"gen": cmd_gen,
"submit": cmd_submit,
"sample-session": cmd_sample_session,
"sample-order": cmd_sample_order,
}[args.cmd](args)
elif args.module == "mall": elif args.module == "mall":
return {"verify": cmd_mall_verify, "gen": cmd_mall_gen, "submit": cmd_mall_submit, return {
"sample-session": cmd_mall_sample, "pay": cmd_mall_pay, "auto": cmd_mall_auto, "verify": cmd_mall_verify,
"capture": cmd_mall_capture}[args.cmd](args) "gen": cmd_mall_gen,
"submit": cmd_mall_submit,
"sample-session": cmd_mall_sample,
"pay": cmd_mall_pay,
"auto": cmd_mall_auto,
"capture": cmd_mall_capture,
}[args.cmd](args)
return 2 return 2
except (FileNotFoundError, ValueError) as e: except (FileNotFoundError, ValueError) as e:
print(f"错误: {e}") print(f"错误: {e}")
return 2 return 2
def build_mall_transform(fixed: dict) -> list: def build_mall_transform(fixed: dict) -> list:
"""仅CK全自动:固定槽模板 + 随机 key16/槽6/明文缓冲 构造 transform_input。 """仅CK全自动:固定槽模板 + 随机 key16/槽6/明文缓冲 构造 transform_input。
@@ -487,6 +582,7 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]:
证据: evidence/getpaytoken-pure-http.json 证据: evidence/getpaytoken-pure-http.json
""" """
import urllib.request import urllib.request
login = midas_login_params(cookies) login = midas_login_params(cookies)
login["offer_id"] = "800001492" login["offer_id"] = "800001492"
body = { body = {
@@ -494,8 +590,12 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]:
"call_param": { "call_param": {
"call_func": "GetPayToken", "call_func": "GetPayToken",
"call_param_json": json.dumps( "call_param_json": json.dumps(
{"version": "pagedoo-v2.0.0", "app_id": "202406061128117473047424", {
"content_id": "ct1755160919_GEOCGTMN"}), "version": "pagedoo-v2.0.0",
"app_id": "202406061128117473047424",
"content_id": "ct1755160919_GEOCGTMN",
}
),
"call_type": "security_service", "call_type": "security_service",
"login_check_param_json": json.dumps(login), "login_check_param_json": json.dumps(login),
}, },
@@ -505,12 +605,19 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]:
for k, v in [("midas_openid", "openid"), ("midas_openkey", "accesstoken")]: for k, v in [("midas_openid", "openid"), ("midas_openkey", "accesstoken")]:
if k not in cookies and v in cookies: if k not in cookies and v in cookies:
cookie_str += f"; {k}=" + cookies[v] cookie_str += f"; {k}=" + cookies[v]
req = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), headers={ req = urllib.request.Request(
"Content-Type": "application/json", "Accept": "application/json, text/plain, */*", url,
"Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/", data=json.dumps(body).encode("utf-8"),
"Cookie": cookie_str, headers={
"User-Agent": MALL_USER_AGENT, "Content-Type": "application/json",
}, method="POST") "Accept": "application/json, text/plain, */*",
"Origin": "https://z.iwan.yyb.qq.com",
"Referer": "https://z.iwan.yyb.qq.com/",
"Cookie": cookie_str,
"User-Agent": MALL_USER_AGENT,
},
method="POST",
)
try: try:
with urllib.request.urlopen(req, timeout=30) as r: with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read().decode("utf-8", "replace") raw = r.read().decode("utf-8", "replace")
@@ -549,7 +656,11 @@ def _apply_card_selection(payload: dict, args) -> None:
if quantity is not None and quantity <= 0: if quantity is not None and quantity <= 0:
raise ValueError("--quantity 必须是正整数") raise ValueError("--quantity 必须是正整数")
products = payload.get("product_list") products = payload.get("product_list")
if not isinstance(products, list) or not products or not isinstance(products[0], dict): if (
not isinstance(products, list)
or not products
or not isinstance(products[0], dict)
):
raise ValueError("订单模板缺少 product_list[0]") raise ValueError("订单模板缺少 product_list[0]")
product = products[0] product = products[0]
product_id = getattr(args, "product_id", None) product_id = getattr(args, "product_id", None)
@@ -617,7 +728,9 @@ def cmd_mall_auto(args) -> int:
return 1 return 1
print(f"[auto] arrays {len(arrays)} | pay_token {pay_token[:16]}...") print(f"[auto] arrays {len(arrays)} | pay_token {pay_token[:16]}...")
print("[auto] ② 构造 transform_input(固定槽 + 随机 key/明文缓冲)...") print("[auto] ② 构造 transform_input(固定槽 + 随机 key/明文缓冲)...")
fixed = json.loads((ROOT / "replay/mall/transform-fixed.json").read_text(encoding="utf-8")) fixed = json.loads(
(ROOT / "replay/mall/transform-fixed.json").read_text(encoding="utf-8")
)
validate_mall_materials(fixed) validate_mall_materials(fixed)
ti = build_mall_transform(fixed) ti = build_mall_transform(fixed)
print("[auto] ③ 纯 Python 生成 encrypt_msg...") print("[auto] ③ 纯 Python 生成 encrypt_msg...")
@@ -639,18 +752,29 @@ def cmd_mall_auto(args) -> int:
if k not in cookies and v in cookies: if k not in cookies and v in cookies:
cookie_str += f"; {k}=" + cookies[v] cookie_str += f"; {k}=" + cookies[v]
url = MALL_API_URL + "?t=" + str(int(time.time() * 1000)) url = MALL_API_URL + "?t=" + str(int(time.time() * 1000))
req = urllib.request.Request(url, data=new_body.encode("utf-8"), headers={ req = urllib.request.Request(
"Content-Type": "application/json", "Accept": "application/json, text/plain, */*", url,
"Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/", data=new_body.encode("utf-8"),
"Cookie": cookie_str, headers={
"User-Agent": MALL_USER_AGENT, "Content-Type": "application/json",
}, method="POST") "Accept": "application/json, text/plain, */*",
"Origin": "https://z.iwan.yyb.qq.com",
"Referer": "https://z.iwan.yyb.qq.com/",
"Cookie": cookie_str,
"User-Agent": MALL_USER_AGENT,
},
method="POST",
)
try: try:
with urllib.request.urlopen(req, timeout=30) as r: with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read().decode("utf-8", "replace") raw = r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", "replace") raw = e.read().decode("utf-8", "replace")
out = Path(args.output) if args.output else ROOT / "config" / "mall-order-response.json" out = (
Path(args.output)
if args.output
else ROOT / "config" / "mall-order-response.json"
)
_write_private_text(out, raw) _write_private_text(out, raw)
js = json.loads(raw) js = json.loads(raw)
ret = js.get("result_code") ret = js.get("result_code")
@@ -669,7 +793,9 @@ def cmd_mall_auto(args) -> int:
return 0 return 0
detail = js.get("result_info", "") or call_reply.get("result_info", "") or "" detail = js.get("result_info", "") or call_reply.get("result_info", "") or ""
print(f"❌ 下单失败 ret={ret} inner={inner_ret} ({detail})") print(f"❌ 下单失败 ret={ret} inner={inner_ret} ({detail})")
print(f" {describe_payment_failure(str(cookies.get('logintype', '')), 'order', f'{ret} {inner_ret} {detail}')}") print(
f" {describe_payment_failure(str(cookies.get('logintype', '')), 'order', f'{ret} {inner_ret} {detail}')}"
)
print(f" 响应已保存 → {out}") print(f" 响应已保存 → {out}")
return 1 return 1
@@ -714,10 +840,13 @@ def cmd_mall_pay(args) -> int:
cr = json.loads(resp["data"]["call_reply"]) cr = json.loads(resp["data"]["call_reply"])
up = cr["data"]["url_params"] up = cr["data"]["url_params"]
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
q = parse_qs(urlparse(up).query) q = parse_qs(urlparse(up).query)
mall_tid = q.get("token_id", [""])[0] mall_tid = q.get("token_id", [""])[0]
if mall_tid and mall_tid != token_id: if mall_tid and mall_tid != token_id:
print(f"⚠️ mall 响应订单({mall_tid[:16]}...)与捕获会话订单({token_id[:16]}...)不一致") print(
f"⚠️ mall 响应订单({mall_tid[:16]}...)与捕获会话订单({token_id[:16]}...)不一致"
)
print(" goods web_token 绑定捕获页面订单,以捕获 body 订单为准继续") print(" goods web_token 绑定捕获页面订单,以捕获 body 订单为准继续")
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
print(f"⚠️ mall 响应读取失败(忽略): {e!r}") print(f"⚠️ mall 响应读取失败(忽略): {e!r}")
@@ -727,6 +856,7 @@ def cmd_mall_pay(args) -> int:
keys = json.loads((gd / "keys.json").read_text(encoding="utf-8")) keys = json.loads((gd / "keys.json").read_text(encoding="utf-8"))
xmidas = json.loads((gd / "xmidasops.json").read_text(encoding="utf-8")) xmidas = json.loads((gd / "xmidasops.json").read_text(encoding="utf-8"))
from pyvm.algorithm import decode_d, recover_plaintext_from_buffer from pyvm.algorithm import decode_d, recover_plaintext_from_buffer
at_file = gd / "args-template.json" at_file = gd / "args-template.json"
if at_file.exists(): if at_file.exists():
# 新版采集:decoded 18参直接读取(capture-goods-session.mjs J 转储) # 新版采集:decoded 18参直接读取(capture-goods-session.mjs J 转储)
@@ -740,8 +870,10 @@ def cmd_mall_pay(args) -> int:
if not web_token: if not web_token:
m = re.search(r"web_token=([0-9A-F]+)", body_tpl) m = re.search(r"web_token=([0-9A-F]+)", body_tpl)
web_token = m.group(1) if m else "" web_token = m.group(1) if m else ""
print(f"[pay] goods 会话态: xmidasops={len(xmidas)} key16={'' if keys.get('key16') else ''} " print(
f"web_token={web_token[:12]}...") f"[pay] goods 会话态: xmidasops={len(xmidas)} key16={'' if keys.get('key16') else ''} "
f"web_token={web_token[:12]}..."
)
# 3. 恢复页面真实明文(ts/fk_extend/_rand),刷新 ts 为当前一致值 # 3. 恢复页面真实明文(ts/fk_extend/_rand),刷新 ts 为当前一致值
try: try:
@@ -761,22 +893,37 @@ def cmd_mall_pay(args) -> int:
now_ms = int(time.time() * 1000) now_ms = int(time.time() * 1000)
params["ts"] = str(now_ms // 1000) params["ts"] = str(now_ms // 1000)
if rec_ts: if rec_ts:
print(f"[pay] 页面真实明文: ts={rec_ts}(捕获) -> 使用当前 ts={params['ts']} " print(
f"(E3:ts 与 body t 一致即可,不必等于页面值)") f"[pay] 页面真实明文: ts={rec_ts}(捕获) -> 使用当前 ts={params['ts']} "
f"(E3:ts 与 body t 一致即可,不必等于页面值)"
)
print("[pay] 生成 goods encrypt_msg(纯 Python)...") print("[pay] 生成 goods encrypt_msg(纯 Python)...")
hex_msg = generate_encrypt_msg_offline( hex_msg = generate_encrypt_msg_offline(
params, fk_extend, params["ts"], rand_val, params,
xmidas=xmidas, args_template=web_args, fk_extend,
key16=keys.get("key16"), key1=keys.get("key1"), params["ts"],
rand_val,
xmidas=xmidas,
args_template=web_args,
key16=keys.get("key16"),
key1=keys.get("key1"),
) )
print(f"[pay] encrypt_msg ({len(hex_msg)} hex): {hex_msg[:32]}...") print(f"[pay] encrypt_msg ({len(hex_msg)} hex): {hex_msg[:32]}...")
# 4. 构造 web_save body(会话订单 token + 当前动态值 + 纯 py encrypt_msg) # 4. 构造 web_save body(会话订单 token + 当前动态值 + 纯 py encrypt_msg)
body = body_tpl body = body_tpl
for k, v in [("token_id", token_id), ("transaction_id", transaction_id), for k, v in [
("out_trade_no", out_trade_no), ("offer_type", offer_type)]: ("token_id", token_id),
("transaction_id", transaction_id),
("out_trade_no", out_trade_no),
("offer_type", offer_type),
]:
body = re.sub(rf"{k}=[^&]*", f"{k}=" + v, body, count=1) body = re.sub(rf"{k}=[^&]*", f"{k}=" + v, body, count=1)
body = re.sub(r"pc_st=[^&]+", "pc_st=" + str(uuid.uuid4()).upper() + str(int(time.time() * 1000)), body) body = re.sub(
r"pc_st=[^&]+",
"pc_st=" + str(uuid.uuid4()).upper() + str(int(time.time() * 1000)),
body,
)
body = re.sub(r"r=[0-9.]+", "r=" + str(secrets.SystemRandom().random()), body) body = re.sub(r"r=[0-9.]+", "r=" + str(secrets.SystemRandom().random()), body)
body = re.sub(r"&t=[0-9]+", "&t=" + str(int(time.time() * 1000)), body) body = re.sub(r"&t=[0-9]+", "&t=" + str(int(time.time() * 1000)), body)
body = re.sub(r"encrypt_msg=[0-9a-f]+", "encrypt_msg=" + hex_msg, body) body = re.sub(r"encrypt_msg=[0-9a-f]+", "encrypt_msg=" + hex_msg, body)
@@ -798,7 +945,8 @@ def cmd_mall_pay(args) -> int:
# 6. POST web_save # 6. POST web_save
url = f"https://api.unipay.qq.com/v1/r/{args.appid}/web_save" url = f"https://api.unipay.qq.com/v1/r/{args.appid}/web_save"
req = urllib.request.Request( req = urllib.request.Request(
url, data=body.encode("utf-8"), url,
data=body.encode("utf-8"),
headers={ headers={
"User-Agent": MALL_USER_AGENT, "User-Agent": MALL_USER_AGENT,
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
@@ -855,5 +1003,6 @@ def cmd_mall_pay(args) -> int:
pass pass
return 0 return 0
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())
@@ -6,6 +6,7 @@
- mall.py: mall 侧高层 API(MallSession + generate_encrypt_msg) - mall.py: mall 侧高层 API(MallSession + generate_encrypt_msg)
- session.py: 会话态模型 - session.py: 会话态模型
""" """
from .algorithm import ( from .algorithm import (
build_plaintext, build_plaintext,
decode_d, decode_d,
File diff suppressed because it is too large Load Diff
+53 -13
View File
@@ -7,6 +7,7 @@
webSave(33 块变换) webSave(33 块变换)
encrypt_msg(1056 hex) encrypt_msg(1056 hex)
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -16,17 +17,39 @@ from typing import Any
from .algorithm import generate_encrypt_msg_offline from .algorithm import generate_encrypt_msg_offline
REPLAY = Path(__file__).resolve().parent.parent / "replay" REPLAY = Path(__file__).resolve().parent.parent / "replay"
ORDER_FIELDS = ["token_id", "openid", "openkey", "session_id", "session_type", "zoneid", ORDER_FIELDS = [
"pay_method", "buy_quantity", "mb_pwd", "pay_id", "auth_key", "token_id",
"card_value", "accounttype", "provide_uin", "extend", "ts", "openid",
"from_h5", "webversion"] "openkey",
"session_id",
"session_type",
"zoneid",
"pay_method",
"buy_quantity",
"mb_pwd",
"pay_id",
"auth_key",
"card_value",
"accounttype",
"provide_uin",
"extend",
"ts",
"from_h5",
"webversion",
]
class GoodsSession: class GoodsSession:
"""goods 会话态:xMidasOps(59640,服务端生成)+ key16/key1 + args_template。""" """goods 会话态:xMidasOps(59640,服务端生成)+ key16/key1 + args_template。"""
def __init__(self, xmidas_ops: list, key16: list, key1: list, def __init__(
args_template_d: str = "", xmidas_token: str = ""): self,
xmidas_ops: list,
key16: list,
key1: list,
args_template_d: str = "",
xmidas_token: str = "",
):
self.xmidas_ops = xmidas_ops self.xmidas_ops = xmidas_ops
self.key16 = key16 self.key16 = key16
self.key1 = key1 self.key1 = key1
@@ -44,8 +67,13 @@ class GoodsSession:
def from_session_state(cls, path: str | Path) -> "GoodsSession": def from_session_state(cls, path: str | Path) -> "GoodsSession":
"""从 scripts/capture-session.mjs 生成的 session-state.json 加载。""" """从 scripts/capture-session.mjs 生成的 session-state.json 加载。"""
d = json.loads(Path(path).read_text(encoding="utf-8")) d = json.loads(Path(path).read_text(encoding="utf-8"))
return cls(d["xmidas_ops"], d["key16"], d["key1"], return cls(
d.get("args_template_d", ""), d.get("xmidas_token", "")) d["xmidas_ops"],
d["key16"],
d["key1"],
d.get("args_template_d", ""),
d.get("xmidas_token", ""),
)
def to_json(self) -> dict: def to_json(self) -> dict:
return { return {
@@ -58,17 +86,29 @@ class GoodsSession:
@classmethod @classmethod
def from_json(cls, d: dict) -> "GoodsSession": def from_json(cls, d: dict) -> "GoodsSession":
return cls(d["xmidas_ops"], d["key16"], d["key1"], return cls(
d.get("args_template_d", ""), d.get("xmidas_token", "")) d["xmidas_ops"],
d["key16"],
d["key1"],
d.get("args_template_d", ""),
d.get("xmidas_token", ""),
)
def generate_encrypt_msg(session: GoodsSession, order: dict) -> str: def generate_encrypt_msg(session: GoodsSession, order: dict) -> str:
"""用会话态 + 订单参数生成 goods encrypt_msg(1056 hex)。""" """用会话态 + 订单参数生成 goods encrypt_msg(1056 hex)。"""
from .algorithm import decode_d from .algorithm import decode_d
params = {k: order.get(k, "") for k in ORDER_FIELDS} params = {k: order.get(k, "") for k in ORDER_FIELDS}
args_tpl = decode_d(session.args_template_d) if session.args_template_d else None args_tpl = decode_d(session.args_template_d) if session.args_template_d else None
return generate_encrypt_msg_offline( return generate_encrypt_msg_offline(
params, order.get("fk_extend", ""), order.get("ts", ""), order.get("_rand", ""), params,
xmidas=session.xmidas_ops, xmidas_token=session.xmidas_token, order.get("fk_extend", ""),
args_template=args_tpl, key16=session.key16, key1=session.key1, order.get("ts", ""),
order.get("_rand", ""),
xmidas=session.xmidas_ops,
xmidas_token=session.xmidas_token,
args_template=args_tpl,
key16=session.key16,
key1=session.key1,
) )
@@ -1,4 +1,5 @@
"""YYB mall APIs require different session fields for WeChat and QQ OAuth.""" """YYB mall APIs require different session fields for WeChat and QQ OAuth."""
from __future__ import annotations from __future__ import annotations
WECHAT_APPID = "wxd44977328b36e647" WECHAT_APPID = "wxd44977328b36e647"
+52 -10
View File
@@ -12,6 +12,7 @@ mall 加密链路(E3 已验证,U-2022 闭环):
注意:xMidasOps mall 详情页页面级数据表(59620 长度,服务端生成), 注意:xMidasOps mall 详情页页面级数据表(59620 长度,服务端生成),
必须从浏览器捕获( goods 59640 不同) 必须从浏览器捕获( goods 59640 不同)
""" """
from __future__ import annotations from __future__ import annotations
import copy import copy
@@ -24,9 +25,28 @@ from .algorithm import UNDEF, JSObject, Window
from .pagedoo_vm import PagedooVM from .pagedoo_vm import PagedooVM
REPLAY = Path(__file__).resolve().parent.parent / "replay" / "mall" REPLAY = Path(__file__).resolve().parent.parent / "replay" / "mall"
GLOBALS = [UNDEF, None, True, False, 4294967295, 3995986053, 2103143698, 1622111212, GLOBALS = [
4263108271, 3162892160, 1960464030, 2867129963, 3224029870, 3514649446, UNDEF,
1382846327, 1898428403, 1268470028, 1457769175, 1595352606, 1100935262] None,
True,
False,
4294967295,
3995986053,
2103143698,
1622111212,
4263108271,
3162892160,
1960464030,
2867129963,
3224029870,
3514649446,
1382846327,
1898428403,
1268470028,
1457769175,
1595352606,
1100935262,
]
class MallSession: class MallSession:
@@ -45,9 +65,13 @@ class MallSession:
def validate(self) -> None: def validate(self) -> None:
if len(self.transform_input) != 18: if len(self.transform_input) != 18:
raise ValueError(f"transform_input 应为 18 槽,实际 {len(self.transform_input)}") raise ValueError(
f"transform_input 应为 18 槽,实际 {len(self.transform_input)}"
)
if len(self.xmidas_ops) != 59620: if len(self.xmidas_ops) != 59620:
raise ValueError(f"mall xMidasOps 应为 59620(非 goods 59640),实际 {len(self.xmidas_ops)}") raise ValueError(
f"mall xMidasOps 应为 59620(非 goods 59640),实际 {len(self.xmidas_ops)}"
)
mid = self.transform_input[10] mid = self.transform_input[10]
if isinstance(mid, list) and mid and isinstance(mid[0], list): if isinstance(mid, list) and mid and isinstance(mid[0], list):
if len(mid[0]) != 624: if len(mid[0]) != 624:
@@ -61,7 +85,11 @@ class MallSession:
- J|...|e377650|{JSON} e377650 创建参数(transform_input) - J|...|e377650|{JSON} e377650 创建参数(transform_input)
- XMIDAS_OPS|url|59620数组 mall 详情页 xMidasOps - XMIDAS_OPS|url|59620数组 mall 详情页 xMidasOps
""" """
lines = Path(frames_jsonl).read_text(encoding="utf-8", errors="replace").splitlines() lines = (
Path(frames_jsonl)
.read_text(encoding="utf-8", errors="replace")
.splitlines()
)
transform_input = None transform_input = None
xmidas = None xmidas = None
# xMidasOps:取 mall 详情页(z.iwan / pagedoo)那条 # xMidasOps:取 mall 详情页(z.iwan / pagedoo)那条
@@ -85,7 +113,9 @@ class MallSession:
transform_input = json.loads(l.split("|e377650|", 1)[1]) transform_input = json.loads(l.split("|e377650|", 1)[1])
break break
if transform_input is None: if transform_input is None:
raise ValueError("frames.jsonl 中未找到 e377650 J 转储(需 mall 详情页购买触发加密)") raise ValueError(
"frames.jsonl 中未找到 e377650 J 转储(需 mall 详情页购买触发加密)"
)
if xmidas is None: if xmidas is None:
raise ValueError("frames.jsonl 中未找到 59620 长度 xMidasOps") raise ValueError("frames.jsonl 中未找到 59620 长度 xMidasOps")
return cls(transform_input, xmidas) return cls(transform_input, xmidas)
@@ -112,8 +142,18 @@ def _mk_window(xmidas: list) -> Window:
w.set("sessionStorage", JSObject()) w.set("sessionStorage", JSObject())
w.set("screen", JSObject()) w.set("screen", JSObject())
w.set("history", JSObject()) w.set("history", JSObject())
w.set("XMLHttpRequest", type("XHR", (), { w.set(
"open": lambda *a: None, "send": lambda *a: None, "setRequestHeader": lambda *a: None})) "XMLHttpRequest",
type(
"XHR",
(),
{
"open": lambda *a: None,
"send": lambda *a: None,
"setRequestHeader": lambda *a: None,
},
),
)
w.set("fetch", lambda *a: None) w.set("fetch", lambda *a: None)
w.set("xMidasOps", xmidas) w.set("xMidasOps", xmidas)
return w return w
@@ -143,5 +183,7 @@ def generate_encrypt_msg(session: MallSession, random_seed: int = 1) -> str:
h9 = h[9][0] if isinstance(h[9], list) and h[9] else h[9] h9 = h[9][0] if isinstance(h[9], list) and h[9] else h[9]
if not isinstance(h9, list) or len(h9) != 624: if not isinstance(h9, list) or len(h9) != 624:
raise RuntimeError(f"e377650 输出异常: {type(h9).__name__} len={len(h9) if isinstance(h9, list) else '?'}") raise RuntimeError(
f"e377650 输出异常: {type(h9).__name__} len={len(h9) if isinstance(h9, list) else '?'}"
)
return "".join(f"{x & 255:02x}" for x in h9) return "".join(f"{x & 255:02x}" for x in h9)
@@ -1,4 +1,5 @@
"""Read YYB's official order list after a payment is completed.""" """Read YYB's official order list after a payment is completed."""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
@@ -70,7 +71,9 @@ def order_completion_states(document: dict[str, Any]) -> dict[str, bool]:
} }
def find_completed_order(document: dict[str, Any], previous_states: dict[str, bool]) -> dict[str, Any] | None: def find_completed_order(
document: dict[str, Any], previous_states: dict[str, bool]
) -> dict[str, Any] | None:
"""Find an order that appeared or transitioned to completed after the QR display.""" """Find an order that appeared or transitioned to completed after the QR display."""
for item in document.get("list", []): for item in document.get("list", []):
if not isinstance(item, dict): if not isinstance(item, dict):
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
"""YYB 下单和付款失败响应的统一归类。""" """YYB 下单和付款失败响应的统一归类。"""
from __future__ import annotations from __future__ import annotations
+29 -7
View File
@@ -2,6 +2,7 @@
这里仅收敛已由成功请求验证的固定值不负责推断或修改协议字段 这里仅收敛已由成功请求验证的固定值不负责推断或修改协议字段
""" """
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
@@ -47,21 +48,38 @@ def file_fingerprint(path: Path) -> str:
return _sha256_prefix_bytes(path.read_bytes()) return _sha256_prefix_bytes(path.read_bytes())
def validate_goods_materials(args_template: list, xmidas: list[int] | None = None) -> None: def validate_goods_materials(
args_template: list, xmidas: list[int] | None = None
) -> None:
"""校验 goods webSave VM 所需静态表结构,发现升级时尽早失败。""" """校验 goods webSave VM 所需静态表结构,发现升级时尽早失败。"""
if not isinstance(args_template, list) or len(args_template) != 18: if not isinstance(args_template, list) or len(args_template) != 18:
raise ValueError("goods args-template 必须是 18 槽数组") raise ValueError("goods args-template 必须是 18 槽数组")
for index in (0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16): for index in (0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16):
if not isinstance(args_template[index], list) or not args_template[index]: if not isinstance(args_template[index], list) or not args_template[index]:
raise ValueError(f"goods args-template 槽 {index} 缺失") raise ValueError(f"goods args-template 槽 {index} 缺失")
expected_lengths = {0: 16, 1: 256, 2: 256, 3: 256, 4: 256, 5: 256, expected_lengths = {
6: 16, 10: 528, 11: 1024, 12: 1024, 13: 256, 0: 16,
14: 256, 15: 256, 16: 256} 1: 256,
2: 256,
3: 256,
4: 256,
5: 256,
6: 16,
10: 528,
11: 1024,
12: 1024,
13: 256,
14: 256,
15: 256,
16: 256,
}
for index, expected in expected_lengths.items(): for index, expected in expected_lengths.items():
value = args_template[index][0] value = args_template[index][0]
if not isinstance(value, list) or len(value) != expected: if not isinstance(value, list) or len(value) != expected:
actual = len(value) if isinstance(value, list) else "非数组" actual = len(value) if isinstance(value, list) else "非数组"
raise ValueError(f"goods args-template 槽 {index} 长度异常: {actual} != {expected}") raise ValueError(
f"goods args-template 槽 {index} 长度异常: {actual} != {expected}"
)
if xmidas is not None and len(xmidas) != 59640: if xmidas is not None and len(xmidas) != 59640:
raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)} != 59640") raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)} != 59640")
@@ -70,7 +88,9 @@ def validate_mall_materials(transform_fixed: dict[str, Any]) -> None:
"""校验 mall 固定槽模板;动态槽仍由当前会话的 GetPayToken 填充。""" """校验 mall 固定槽模板;动态槽仍由当前会话的 GetPayToken 填充。"""
if not isinstance(transform_fixed, dict): if not isinstance(transform_fixed, dict):
raise ValueError("mall transform-fixed 必须是对象") raise ValueError("mall transform-fixed 必须是对象")
required = {str(index) for index in (1, 2, 3, 4, 5, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17)} required = {
str(index) for index in (1, 2, 3, 4, 5, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17)
}
missing = sorted(required - set(transform_fixed)) missing = sorted(required - set(transform_fixed))
if missing: if missing:
raise ValueError(f"mall transform-fixed 缺少槽: {', '.join(missing)}") raise ValueError(f"mall transform-fixed 缺少槽: {', '.join(missing)}")
@@ -79,7 +99,9 @@ def validate_mall_materials(transform_fixed: dict[str, Any]) -> None:
raise ValueError(f"mall transform-fixed 槽 {index} 不是数组") raise ValueError(f"mall transform-fixed 槽 {index} 不是数组")
def goods_material_diagnostics(root: Path, args_template: list, xmidas: list[int]) -> dict[str, Any]: def goods_material_diagnostics(
root: Path, args_template: list, xmidas: list[int]
) -> dict[str, Any]:
"""构造脱敏协议指纹,便于区分页面升级和服务端业务拒绝。""" """构造脱敏协议指纹,便于区分页面升级和服务端业务拒绝。"""
validate_goods_materials(args_template, xmidas) validate_goods_materials(args_template, xmidas)
replay = root / "replay" replay = root / "replay"
+4 -1
View File
@@ -6,6 +6,7 @@ F-2049/F-2051(E3):encrypt_msg 与当前会话绑定——
- key1: 诱饵密钥(点击级,捕获即可) - key1: 诱饵密钥(点击级,捕获即可)
服务端能验证 key 派生状态(随机 key 变体 ret:1099),因此新订单必须先捕获会话态 服务端能验证 key 派生状态(随机 key 变体 ret:1099),因此新订单必须先捕获会话态
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -31,7 +32,9 @@ class SessionState:
key16: list[int] = field(default_factory=list) key16: list[int] = field(default_factory=list)
key1: list[int] = field(default_factory=list) key1: list[int] = field(default_factory=list)
xmidas_token: str = DEFAULT_XMIDAS_TOKEN xmidas_token: str = DEFAULT_XMIDAS_TOKEN
args_template_d: str = "" # webSave 18 参深拷贝(deepcap PC 85091 的 C[2] 原始 D 编码,会话绑定) args_template_d: str = (
"" # webSave 18 参深拷贝(deepcap PC 85091 的 C[2] 原始 D 编码,会话绑定)
)
cookies: dict[str, str] = field(default_factory=dict) cookies: dict[str, str] = field(default_factory=dict)
openid: str = "" openid: str = ""
openkey: str = "" openkey: str = ""
+254 -78
View File
@@ -5,6 +5,7 @@
付款码只在服务端 ``web_save`` 返回 ``ret=0`` 后渲染付款后通过商城官方订单 付款码只在服务端 ``web_save`` 返回 ``ret=0`` 后渲染付款后通过商城官方订单
列表确认本次新出现的完成订单该检查不触发付款或确认操作 列表确认本次新出现的完成订单该检查不触发付款或确认操作
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@@ -26,7 +27,11 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from pyvm.algorithm import build_plaintext, derive_key1_from_key16, generate_encrypt_msg_offline # noqa: E402 from pyvm.algorithm import (
build_plaintext,
derive_key1_from_key16,
generate_encrypt_msg_offline,
) # noqa: E402
from pyvm.login_profile import midas_login_params # noqa: E402 from pyvm.login_profile import midas_login_params # noqa: E402
from pyvm.payment_errors import describe_payment_failure # noqa: E402 from pyvm.payment_errors import describe_payment_failure # noqa: E402
from pyvm.protocol import ( # noqa: E402 from pyvm.protocol import ( # noqa: E402
@@ -64,6 +69,8 @@ def write_private_text(path: Path, content: str) -> None:
def write_private_json(path: Path, value: dict) -> None: def write_private_json(path: Path, value: dict) -> None:
write_private_text(path, json.dumps(value, ensure_ascii=False, indent=2) + "\n") write_private_text(path, json.dumps(value, ensure_ascii=False, indent=2) + "\n")
def load_json(path: Path) -> dict: def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8")) return json.loads(path.read_text(encoding="utf-8"))
@@ -74,8 +81,12 @@ def parse_url_params(mall_response: dict) -> dict[str, str]:
url_params = call_reply["data"]["url_params"] url_params = call_reply["data"]["url_params"]
except (KeyError, TypeError, json.JSONDecodeError) as exc: except (KeyError, TypeError, json.JSONDecodeError) as exc:
raise ValueError("mall 响应缺少 data.call_reply.data.url_params") from exc raise ValueError("mall 响应缺少 data.call_reply.data.url_params") from exc
return {key: values[-1] for key, values in urllib.parse.parse_qs( return {
urllib.parse.urlparse(url_params).query, keep_blank_values=True).items()} key: values[-1]
for key, values in urllib.parse.parse_qs(
urllib.parse.urlparse(url_params).query, keep_blank_values=True
).items()
}
def cookie_header(cookies: dict[str, str]) -> str: def cookie_header(cookies: dict[str, str]) -> str:
@@ -96,8 +107,9 @@ def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None)
if body is not None: if body is not None:
headers["Content-Type"] = "application/x-www-form-urlencoded" headers["Content-Type"] = "application/x-www-form-urlencoded"
headers["Origin"] = "https://pay.qq.com" headers["Origin"] = "https://pay.qq.com"
request = urllib.request.Request(url, data=body, headers=headers, request = urllib.request.Request(
method="POST" if body is not None else "GET") url, data=body, headers=headers, method="POST" if body is not None else "GET"
)
try: try:
with urllib.request.urlopen(request, timeout=30) as response: with urllib.request.urlopen(request, timeout=30) as response:
return response.read().decode("utf-8", "replace") return response.read().decode("utf-8", "replace")
@@ -105,7 +117,9 @@ def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None)
return exc.read().decode("utf-8", "replace") return exc.read().decode("utf-8", "replace")
def goods_page_url(cookies: dict[str, str], order: dict[str, str], zone_id: str = "1", pf: str = "") -> str: def goods_page_url(
cookies: dict[str, str], order: dict[str, str], zone_id: str = "1", pf: str = ""
) -> str:
openid = cookies.get("openid") or cookies.get("midas_openid") openid = cookies.get("openid") or cookies.get("midas_openid")
openkey = cookies.get("accesstoken") or cookies.get("midas_openkey") openkey = cookies.get("accesstoken") or cookies.get("midas_openkey")
if not openid or not openkey: if not openid or not openkey:
@@ -135,7 +149,9 @@ def extract_goods_state(html: str) -> tuple[list[int], str, str]:
token = re.search(r'id="xMidasToken"\s+value="([0-9A-Fa-f]+)"', html) token = re.search(r'id="xMidasToken"\s+value="([0-9A-Fa-f]+)"', html)
anti = re.search(r'id="antiAutoScriptToken"\s+value="([0-9A-Fa-f]+)"', html) anti = re.search(r'id="antiAutoScriptToken"\s+value="([0-9A-Fa-f]+)"', html)
if not ops or not token or not anti: if not ops or not token or not anti:
raise ValueError("goods 页面缺少 xMidasOps/xMidasToken/antiAutoScriptToken,登录态或订单已失效") raise ValueError(
"goods 页面缺少 xMidasOps/xMidasToken/antiAutoScriptToken,登录态或订单已失效"
)
xmidas = [int(value) for value in ops.group(1).split(",") if value] xmidas = [int(value) for value in ops.group(1).split(",") if value]
if len(xmidas) != 59640: if len(xmidas) != 59640:
raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)}") raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)}")
@@ -147,16 +163,25 @@ def load_template_args() -> list:
runtime_args = runtime_template / "args-template.json" runtime_args = runtime_template / "args-template.json"
if runtime_args.exists(): if runtime_args.exists():
return load_json(runtime_args) return load_json(runtime_args)
candidates = sorted((ROOT / "replay/live").glob("*/args-template.json"), reverse=True) candidates = sorted(
(ROOT / "replay/live").glob("*/args-template.json"), reverse=True
)
for path in candidates: for path in candidates:
if path.exists(): if path.exists():
return load_json(path) return load_json(path)
raise FileNotFoundError("缺少归档 goods args-template.json") raise FileNotFoundError("缺少归档 goods args-template.json")
def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token: str, def build_save_fields(
anti_token: str, encrypt_msg: str, zone_id: str, pf: str, order: dict[str, str],
amount_fen: int) -> dict[str, str]: cookies: dict[str, str],
web_token: str,
anti_token: str,
encrypt_msg: str,
zone_id: str,
pf: str,
amount_fen: int,
) -> dict[str, str]:
"""按当前订单构造完整 web_save 表单,不能使用脱敏空模板。""" """按当前订单构造完整 web_save 表单,不能使用脱敏空模板。"""
if amount_fen <= 0: if amount_fen <= 0:
raise ValueError("充值金额必须为正数") raise ValueError("充值金额必须为正数")
@@ -176,7 +201,9 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
"openkey": cookies.get("accesstoken", ""), "openkey": cookies.get("accesstoken", ""),
"session_id": login["session_id"], "session_id": login["session_id"],
"session_type": login["session_type"], "session_type": login["session_type"],
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()).hexdigest().upper(), "sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode())
.hexdigest()
.upper(),
"anti_auto_script_token_id": anti_token, "anti_auto_script_token_id": anti_token,
"zoneid": zone_id, "zoneid": zone_id,
"buy_quantity": "1", "buy_quantity": "1",
@@ -207,17 +234,38 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
return fields return fields
def build_save_body(order: dict[str, str], cookies: dict[str, str], web_token: str, def build_save_body(
anti_token: str, encrypt_msg: str, zone_id: str, pf: str, order: dict[str, str],
amount_fen: int) -> str: cookies: dict[str, str],
web_token: str,
anti_token: str,
encrypt_msg: str,
zone_id: str,
pf: str,
amount_fen: int,
) -> str:
"""编码完整支付表单,确保加密明文和实际请求使用同一上下文。""" """编码完整支付表单,确保加密明文和实际请求使用同一上下文。"""
return urllib.parse.urlencode(build_save_fields( return urllib.parse.urlencode(
order, cookies, web_token, anti_token, encrypt_msg, zone_id, pf, amount_fen, build_save_fields(
)) order,
cookies,
web_token,
anti_token,
encrypt_msg,
zone_id,
pf,
amount_fen,
)
)
def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_token: str, def build_page_info_body(
zone_id: str, pf: str) -> str: order: dict[str, str],
cookies: dict[str, str],
anti_token: str,
zone_id: str,
pf: str,
) -> str:
"""构造网页端 fp-behv 后的 web_page_info 表单。""" """构造网页端 fp-behv 后的 web_page_info 表单。"""
login = midas_login_params(cookies) login = midas_login_params(cookies)
fields = { fields = {
@@ -235,7 +283,9 @@ def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_to
"openkey": cookies.get("accesstoken", ""), "openkey": cookies.get("accesstoken", ""),
"session_id": login["session_id"], "session_id": login["session_id"],
"session_type": login["session_type"], "session_type": login["session_type"],
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()).hexdigest().upper(), "sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode())
.hexdigest()
.upper(),
"anti_auto_script_token_id": anti_token, "anti_auto_script_token_id": anti_token,
"isusempaymode": "1", "isusempaymode": "1",
"zoneid": zone_id, "zoneid": zone_id,
@@ -249,10 +299,13 @@ def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_to
return urllib.parse.urlencode(fields) return urllib.parse.urlencode(fields)
def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str, def make_encrypt_rand(
is_qq_login: bool) -> str: params: dict[str, str], fk_extend: str, ts: str, is_qq_login: bool
) -> str:
"""按登录渠道生成页面已验证形态的 _rand,不能仅按长度替换控制字节。""" """按登录渠道生成页面已验证形态的 _rand,不能仅按长度替换控制字节。"""
prefix = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8)) prefix = "".join(
secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
)
# 微信历史成功请求固定使用 8 个随机字符加 \x01。_rand 位于加密明文中, # 微信历史成功请求固定使用 8 个随机字符加 \x01。_rand 位于加密明文中,
# 末控制字节是协议内容,不能为了对齐擅自替换为 QQ 使用的 \x03。 # 末控制字节是协议内容,不能为了对齐擅自替换为 QQ 使用的 \x03。
if not is_qq_login: if not is_qq_login:
@@ -264,15 +317,27 @@ def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str,
return prefix + ("\x03" * padding_length) return prefix + ("\x03" * padding_length)
def save_web_save_request_meta(out_dir: Path, fields: dict[str, str], device_fp_length: int, def save_web_save_request_meta(
plaintext_length: int) -> None: out_dir: Path, fields: dict[str, str], device_fp_length: int, plaintext_length: int
) -> None:
"""保存可比对的脱敏请求形态,避免在证据目录落盘支付凭据。""" """保存可比对的脱敏请求形态,避免在证据目录落盘支付凭据。"""
sensitive = ("token_id", "transaction_id", "out_trade_no", "openid", "openkey", sensitive = (
"sck", "anti_auto_script_token_id", "web_token", "encrypt_msg") "token_id",
"transaction_id",
"out_trade_no",
"openid",
"openkey",
"sck",
"anti_auto_script_token_id",
"web_token",
"encrypt_msg",
)
fingerprints = { fingerprints = {
name: { name: {
"length": len(fields.get(name, "")), "length": len(fields.get(name, "")),
"sha256_prefix": hashlib.sha256(fields.get(name, "").encode()).hexdigest()[:12], "sha256_prefix": hashlib.sha256(fields.get(name, "").encode()).hexdigest()[
:12
],
} }
for name in sensitive for name in sensitive
} }
@@ -290,7 +355,9 @@ def make_qr(sign: str, output: Path) -> None:
try: try:
import segno import segno
except ImportError as exc: except ImportError as exc:
raise RuntimeError("缺少 segno;请安装后重新执行: python3 -m pip install segno") from exc raise RuntimeError(
"缺少 segno;请安装后重新执行: python3 -m pip install segno"
) from exc
make_private_directory(output.parent) make_private_directory(output.parent)
segno.make(sign).save(str(output), scale=6, border=2) segno.make(sign).save(str(output), scale=6, border=2)
os.chmod(output, 0o600) os.chmod(output, 0o600)
@@ -303,7 +370,9 @@ def node_environment() -> dict[str, str]:
return environment return environment
def save_payment_status(path: Path, document: dict, baseline: dict[str, bool], matched: dict | None = None) -> None: def save_payment_status(
path: Path, document: dict, baseline: dict[str, bool], matched: dict | None = None
) -> None:
"""Persist a small, non-payment-side-effect status record for this run.""" """Persist a small, non-payment-side-effect status record for this run."""
from pyvm.order_status import completion_summary, order_ids from pyvm.order_status import completion_summary, order_ids
@@ -316,8 +385,13 @@ def save_payment_status(path: Path, document: dict, baseline: dict[str, bool], m
write_private_json(path, record) write_private_json(path, record)
def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, bool], def save_payment_meta(
document: dict, portal_serial_no: str = "") -> None: path: Path,
order: dict[str, str],
baseline: dict[str, bool],
document: dict,
portal_serial_no: str = "",
) -> None:
"""Persist the order identifiers and pre-payment baseline needed by a later check. """Persist the order identifiers and pre-payment baseline needed by a later check.
``check`` only re-reads this file and never touches web_save, so re-running a ``check`` only re-reads this file and never touches web_save, so re-running a
@@ -343,7 +417,9 @@ def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, boo
write_private_json(path, record) write_private_json(path, record)
def _match_finished_by_identifiers(listed: list[dict], identifiers: dict[str, str]) -> dict | None: def _match_finished_by_identifiers(
listed: list[dict], identifiers: dict[str, str]
) -> dict | None:
"""Match a finished order by the identifiers recorded at QR creation time. """Match a finished order by the identifiers recorded at QR creation time.
The official order list may not carry token_id/out_trade_no, so no fallback The official order list may not carry token_id/out_trade_no, so no fallback
@@ -397,23 +473,52 @@ def cmd_check_only(session_path: Path, out_dir: Path) -> int:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="YYB: jsdom DeviceFP + web_save -> 微信付款码") parser = argparse.ArgumentParser(
description="YYB: jsdom DeviceFP + web_save -> 微信付款码"
)
parser.add_argument("--session", default=str(ROOT / "config/mall-session.json")) parser.add_argument("--session", default=str(ROOT / "config/mall-session.json"))
parser.add_argument("--mall-response", default=str(ROOT / "config/mall-order-response.json")) parser.add_argument(
parser.add_argument("--out-dir", default=None, help="运行证据目录;默认 config/jsdom-order-<timestamp>") "--mall-response", default=str(ROOT / "config/mall-order-response.json")
)
parser.add_argument(
"--out-dir",
default=None,
help="运行证据目录;默认 config/jsdom-order-<timestamp>",
)
parser.add_argument("--qr", default=None, help="付款二维码 PNG 路径") parser.add_argument("--qr", default=None, help="付款二维码 PNG 路径")
parser.add_argument("--wait", type=int, default=5, help="jsdom 等待 DeviceFP 的秒数") parser.add_argument(
"--wait", type=int, default=5, help="jsdom 等待 DeviceFP 的秒数"
)
parser.add_argument("--zone-id", default="1", help="所选游戏区服 ID") parser.add_argument("--zone-id", default="1", help="所选游戏区服 ID")
parser.add_argument("--pf", default="", help="所选 Android/iOS 支付平台标识") parser.add_argument("--pf", default="", help="所选 Android/iOS 支付平台标识")
parser.add_argument("--amount-fen", type=int, default=0, help="所选点券的价格,单位分(check-only 模式不需要)") parser.add_argument(
parser.add_argument("--dry-run", action="store_true", help="仅拉取页面并生成 DeviceFP,不上报或创建付款码") "--amount-fen",
parser.add_argument("--payment-timeout", type=float, default=300, type=int,
help="付款码生成后等待订单完成的最长秒数") default=0,
parser.add_argument("--payment-interval", type=float, default=3, help="所选点券的价格,单位分(check-only 模式不需要)",
help="订单完成状态检查间隔秒数") )
parser.add_argument("--skip-payment-check", action="store_true", help="仅生成付款码,不等待订单完成") parser.add_argument(
parser.add_argument("--check-only", action="store_true", "--dry-run",
help="只读检测到账(依赖 --out-dir 下已保存的 payment-meta.json),不创建订单") action="store_true",
help="仅拉取页面并生成 DeviceFP,不上报或创建付款码",
)
parser.add_argument(
"--payment-timeout",
type=float,
default=300,
help="付款码生成后等待订单完成的最长秒数",
)
parser.add_argument(
"--payment-interval", type=float, default=3, help="订单完成状态检查间隔秒数"
)
parser.add_argument(
"--skip-payment-check", action="store_true", help="仅生成付款码,不等待订单完成"
)
parser.add_argument(
"--check-only",
action="store_true",
help="只读检测到账(依赖 --out-dir 下已保存的 payment-meta.json),不创建订单",
)
args = parser.parse_args() args = parser.parse_args()
if args.payment_timeout <= 0 or args.payment_interval <= 0: if args.payment_timeout <= 0 or args.payment_interval <= 0:
raise ValueError("--payment-timeout 和 --payment-interval 必须为正数") raise ValueError("--payment-timeout 和 --payment-interval 必须为正数")
@@ -421,7 +526,9 @@ def main() -> int:
session_path = Path(args.session).resolve() session_path = Path(args.session).resolve()
response_path = Path(args.mall_response).resolve() response_path = Path(args.mall_response).resolve()
stamp = time.strftime("%Y%m%d-%H%M%S") stamp = time.strftime("%Y%m%d-%H%M%S")
out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}" out_dir = (
Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
)
if args.check_only: if args.check_only:
make_private_directory(out_dir) make_private_directory(out_dir)
return cmd_check_only(session_path, out_dir) return cmd_check_only(session_path, out_dir)
@@ -444,7 +551,9 @@ def main() -> int:
raise ValueError("mall 响应无法解析 url_params") from exc raise ValueError("mall 响应无法解析 url_params") from exc
stamp = time.strftime("%Y%m%d-%H%M%S") stamp = time.strftime("%Y%m%d-%H%M%S")
out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}" out_dir = (
Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
)
make_private_directory(out_dir) make_private_directory(out_dir)
url = goods_page_url(cookies, order, args.zone_id, args.pf) url = goods_page_url(cookies, order, args.zone_id, args.pf)
print(f"[jsdom-pay] 订单: {order['token_id'][:20]}...") print(f"[jsdom-pay] 订单: {order['token_id'][:20]}...")
@@ -457,9 +566,18 @@ def main() -> int:
fp_path = out_dir / "device-fp.json" fp_path = out_dir / "device-fp.json"
command = [ command = [
"node", str(ROOT / "scripts/generate-devicefp-jsdom.mjs"), "node",
"--html", str(html_path), "--goods-url", url, "--cookies", str(session_path), str(ROOT / "scripts/generate-devicefp-jsdom.mjs"),
"--output", str(fp_path), "--wait", str(args.wait * 1000), "--html",
str(html_path),
"--goods-url",
url,
"--cookies",
str(session_path),
"--output",
str(fp_path),
"--wait",
str(args.wait * 1000),
] ]
print("[jsdom-pay] 运行 jsdom DeviceFP...") print("[jsdom-pay] 运行 jsdom DeviceFP...")
subprocess.run(command, check=True, cwd=ROOT, env=node_environment()) subprocess.run(command, check=True, cwd=ROOT, env=node_environment())
@@ -469,36 +587,53 @@ def main() -> int:
return 0 return 0
print("[jsdom-pay] 上报 fp-behv...") print("[jsdom-pay] 上报 fp-behv...")
fp_response = request_bytes(fp.get("fp_url", FP_URL), cookies, fp["fp_body"].encode()) fp_response = request_bytes(
fp.get("fp_url", FP_URL), cookies, fp["fp_body"].encode()
)
write_private_text(out_dir / "fp-response.json", fp_response) write_private_text(out_dir / "fp-response.json", fp_response)
try: try:
fp_json = json.loads(fp_response) fp_json = json.loads(fp_response)
except json.JSONDecodeError: except json.JSONDecodeError:
fp_json = {} fp_json = {}
if fp_json.get("ret") != 0: if fp_json.get("ret") != 0:
print(f"[jsdom-pay] fp-behv 失败: ret={fp_json.get('ret')} {fp_json.get('msg', '')}") print(
f"[jsdom-pay] fp-behv 失败: ret={fp_json.get('ret')} {fp_json.get('msg', '')}"
)
return 1 return 1
# QQ 成功 HAR 包含该前置;微信历史成功链路没有,不能将 QQ 状态机混入微信请求。 # QQ 成功 HAR 包含该前置;微信历史成功链路没有,不能将 QQ 状态机混入微信请求。
if midas_login_params(cookies)["qq_appid"]: if midas_login_params(cookies)["qq_appid"]:
print("[jsdom-pay] 拉取 QQ web_page_info...") print("[jsdom-pay] 拉取 QQ web_page_info...")
page_info = request_bytes(PAGE_INFO_URL, cookies, build_page_info_body( page_info = request_bytes(
order, cookies, anti_token, args.zone_id, payment_pf, PAGE_INFO_URL,
).encode()) cookies,
build_page_info_body(
order,
cookies,
anti_token,
args.zone_id,
payment_pf,
).encode(),
)
write_private_text(out_dir / "web-page-info-response.json", page_info) write_private_text(out_dir / "web-page-info-response.json", page_info)
try: try:
page_info_json = json.loads(page_info) page_info_json = json.loads(page_info)
except json.JSONDecodeError: except json.JSONDecodeError:
page_info_json = {} page_info_json = {}
if page_info_json.get("ret") != 0: if page_info_json.get("ret") != 0:
print(f"[jsdom-pay] QQ web_page_info 失败: ret={page_info_json.get('ret')} " print(
f"{page_info_json.get('msg', '')}") f"[jsdom-pay] QQ web_page_info 失败: ret={page_info_json.get('ret')} "
f"{page_info_json.get('msg', '')}"
)
print(f"[jsdom-pay] 证据目录: {out_dir}") print(f"[jsdom-pay] 证据目录: {out_dir}")
return 1 return 1
web_args = load_template_args() web_args = load_template_args()
validate_goods_materials(web_args, xmidas) validate_goods_materials(web_args, xmidas)
write_private_json(out_dir / "protocol-materials.json", goods_material_diagnostics(ROOT, web_args, xmidas)) write_private_json(
out_dir / "protocol-materials.json",
goods_material_diagnostics(ROOT, web_args, xmidas),
)
print("[jsdom-pay] 协议材料校验通过") print("[jsdom-pay] 协议材料校验通过")
# key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器 # key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器
# derive_key1_from_key16 求解(key16=Sbox[Te 链(key1)]),满足服务端派生校验, # derive_key1_from_key16 求解(key16=Sbox[Te 链(key1)]),满足服务端派生校验,
@@ -529,18 +664,38 @@ def main() -> int:
now_seconds = str(int(time.time())) now_seconds = str(int(time.time()))
fk_extend = "tdrc_session%3D" + fp["session_id"] fk_extend = "tdrc_session%3D" + fp["session_id"]
random_suffix = make_encrypt_rand( random_suffix = make_encrypt_rand(
params, fk_extend, now_seconds, bool(midas_login_params(cookies)["qq_appid"]), params,
fk_extend,
now_seconds,
bool(midas_login_params(cookies)["qq_appid"]),
)
plaintext_length = len(
build_plaintext(params, fk_extend, now_seconds, random_suffix).encode("latin-1")
) )
plaintext_length = len(build_plaintext(params, fk_extend, now_seconds, random_suffix).encode("latin-1"))
encrypt_msg = generate_encrypt_msg_offline( encrypt_msg = generate_encrypt_msg_offline(
params, fk_extend, now_seconds, random_suffix, params,
key16=key16, key1=key1, args_template=web_args, xmidas=xmidas, xmidas_token=web_token, fk_extend,
now_seconds,
random_suffix,
key16=key16,
key1=key1,
args_template=web_args,
xmidas=xmidas,
xmidas_token=web_token,
) )
fields = build_save_fields( fields = build_save_fields(
order, cookies, web_token, anti_token, encrypt_msg, order,
args.zone_id, payment_pf, args.amount_fen, cookies,
web_token,
anti_token,
encrypt_msg,
args.zone_id,
payment_pf,
args.amount_fen,
)
save_web_save_request_meta(
out_dir, fields, int(fp.get("device_fp_length", 0)), plaintext_length
) )
save_web_save_request_meta(out_dir, fields, int(fp.get("device_fp_length", 0)), plaintext_length)
body = urllib.parse.urlencode(fields) body = urllib.parse.urlencode(fields)
from pyvm.order_status import order_completion_states, get_official_orders from pyvm.order_status import order_completion_states, get_official_orders
@@ -561,10 +716,14 @@ def main() -> int:
except json.JSONDecodeError: except json.JSONDecodeError:
response_json = {} response_json = {}
if response_json.get("ret") != 0: if response_json.get("ret") != 0:
detail = f"ret={response_json.get('ret')} err_code={response_json.get('err_code', '')} " \ detail = (
f"{response_json.get('msg', '')}" f"ret={response_json.get('ret')} err_code={response_json.get('err_code', '')} "
f"{response_json.get('msg', '')}"
)
print(f"[jsdom-pay] web_save 失败: {detail}") print(f"[jsdom-pay] web_save 失败: {detail}")
print(f"[jsdom-pay] {describe_payment_failure(cookies.get('logintype', ''), 'payment', detail)}") print(
f"[jsdom-pay] {describe_payment_failure(cookies.get('logintype', ''), 'payment', detail)}"
)
print(f"[jsdom-pay] 证据目录: {out_dir}") print(f"[jsdom-pay] 证据目录: {out_dir}")
return 1 return 1
sign = response_json.get("info", {}).get("channel_info", {}).get("sign", "") sign = response_json.get("info", {}).get("channel_info", {}).get("sign", "")
@@ -588,7 +747,13 @@ def main() -> int:
baseline_document = {"list": []} baseline_document = {"list": []}
baseline_states = {} baseline_states = {}
print("[jsdom-pay] 记录付款前订单基线与本次订单标识...") print("[jsdom-pay] 记录付款前订单基线与本次订单标识...")
save_payment_meta(out_dir / "payment-meta.json", order, baseline_states, baseline_document, portal_serial_no) save_payment_meta(
out_dir / "payment-meta.json",
order,
baseline_states,
baseline_document,
portal_serial_no,
)
if args.skip_payment_check: if args.skip_payment_check:
print("[jsdom-pay] 已保存基线;后台将单独执行只读到账检测") print("[jsdom-pay] 已保存基线;后台将单独执行只读到账检测")
return 0 return 0
@@ -596,17 +761,23 @@ def main() -> int:
print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...") print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...")
status_path = out_dir / "payment-status.json" status_path = out_dir / "payment-status.json"
save_payment_status(status_path, baseline_document, baseline_states) save_payment_status(status_path, baseline_document, baseline_states)
identifiers = {key: value for key, value in { identifiers = {
"token_id": order.get("token_id", ""), key: value
"transaction_id": order.get("transaction_id", ""), for key, value in {
"out_trade_no": order.get("out_trade_no", ""), "token_id": order.get("token_id", ""),
}.items() if value} "transaction_id": order.get("transaction_id", ""),
"out_trade_no": order.get("out_trade_no", ""),
}.items()
if value
}
deadline = time.monotonic() + args.payment_timeout deadline = time.monotonic() + args.payment_timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
time.sleep(args.payment_interval) time.sleep(args.payment_interval)
document = get_official_orders(cookies) document = get_official_orders(cookies)
completed = _match_finished_by_identifiers( completed = _match_finished_by_identifiers(
[item for item in document.get("list", []) if isinstance(item, dict)], identifiers) [item for item in document.get("list", []) if isinstance(item, dict)],
identifiers,
)
save_payment_status(status_path, document, baseline_states, completed) save_payment_status(status_path, document, baseline_states, completed)
if completed: if completed:
print("[jsdom-pay] 微信付款成功,商城订单已完成。") print("[jsdom-pay] 微信付款成功,商城订单已完成。")
@@ -620,6 +791,11 @@ def main() -> int:
if __name__ == "__main__": if __name__ == "__main__":
try: try:
raise SystemExit(main()) raise SystemExit(main())
except (FileNotFoundError, ValueError, RuntimeError, subprocess.CalledProcessError) as exc: except (
FileNotFoundError,
ValueError,
RuntimeError,
subprocess.CalledProcessError,
) as exc:
print(f"错误: {exc}", file=sys.stderr) print(f"错误: {exc}", file=sys.stderr)
raise SystemExit(2) from exc raise SystemExit(2) from exc
+82 -26
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""应用宝 QQ 二维码登录(纯 Python,无浏览器自动化)。""" """应用宝 QQ 二维码登录(纯 Python,无浏览器自动化)。"""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@@ -15,7 +16,12 @@ from http.cookiejar import CookieJar
from pathlib import Path from pathlib import Path
from urllib.error import HTTPError from urllib.error import HTTPError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import HTTPRedirectHandler, HTTPCookieProcessor, Request, build_opener from urllib.request import (
HTTPRedirectHandler,
HTTPCookieProcessor,
Request,
build_opener,
)
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
OPEN_APPID = "102033112" OPEN_APPID = "102033112"
@@ -60,8 +66,15 @@ class Client:
self.jar = CookieJar() self.jar = CookieJar()
self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar)) self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar))
def request(self, url: str, *, method: str = "GET", body: bytes | None = None, def request(
referer: str = "", headers: dict[str, str] | None = None) -> Response: self,
url: str,
*,
method: str = "GET",
body: bytes | None = None,
referer: str = "",
headers: dict[str, str] | None = None,
) -> Response:
request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"} request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
if referer: if referer:
request_headers["Referer"] = referer request_headers["Referer"] = referer
@@ -155,7 +168,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None:
document["login_type"] = cookies.get("logintype", "QC") document["login_type"] = cookies.get("logintype", "QC")
document["login_updated_at"] = int(time.time()) document["login_updated_at"] = int(time.time())
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".mall-session-", suffix=".tmp", dir=path.parent) fd, temporary = tempfile.mkstemp(
prefix=".mall-session-", suffix=".tmp", dir=path.parent
)
try: try:
with os.fdopen(fd, "w", encoding="utf-8") as handle: with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(document, handle, ensure_ascii=False, indent=2) json.dump(document, handle, ensure_ascii=False, indent=2)
@@ -172,7 +187,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="应用宝 QQ 扫码登录(纯 Python)") parser = argparse.ArgumentParser(description="应用宝 QQ 扫码登录(纯 Python)")
parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json") parser.add_argument(
"--session", type=Path, default=ROOT / "config/mall-session.json"
)
parser.add_argument("--qr", type=Path, default=ROOT / "config/qq-login.jpg") parser.add_argument("--qr", type=Path, default=ROOT / "config/qq-login.jpg")
parser.add_argument("--timeout", type=float, default=300, help="二维码等待秒数") parser.add_argument("--timeout", type=float, default=300, help="二维码等待秒数")
parser.add_argument("--interval", type=float, default=3, help="轮询间隔秒数") parser.add_argument("--interval", type=float, default=3, help="轮询间隔秒数")
@@ -183,8 +200,12 @@ def main() -> int:
client = Client() client = Client()
state = secrets.token_urlsafe(14) state = secrets.token_urlsafe(14)
show_query = { show_query = {
"which": "Login", "display": "pc", "response_type": "code", "client_id": OPEN_APPID, "which": "Login",
"redirect_uri": CALLBACK, "state": state, "display": "pc",
"response_type": "code",
"client_id": OPEN_APPID,
"redirect_uri": CALLBACK,
"state": state,
} }
show_url = f"{GRAPH_SHOW}?{urlencode(show_query)}" show_url = f"{GRAPH_SHOW}?{urlencode(show_query)}"
show = client.request(show_url, referer="https://m.yyb.qq.com/") show = client.request(show_url, referer="https://m.yyb.qq.com/")
@@ -192,11 +213,18 @@ def main() -> int:
raise RuntimeError(f"QQ OAuth 页面请求失败: HTTP {show.status}") raise RuntimeError(f"QQ OAuth 页面请求失败: HTTP {show.status}")
xlogin_query = { xlogin_query = {
"appid": PT_APPID, "daid": PT_DAID, "style": "33", "login_text": "登录", "appid": PT_APPID,
"hide_title_bar": "1", "hide_border": "1", "target": "self", "s_url": LOGIN_JUMP, "daid": PT_DAID,
"style": "33",
"login_text": "登录",
"hide_title_bar": "1",
"hide_border": "1",
"target": "self",
"s_url": LOGIN_JUMP,
"pt_3rd_aid": OPEN_APPID, "pt_3rd_aid": OPEN_APPID,
"pt_feedback_link": f"https://support.qq.com/products/77942?customInfo=.appid{OPEN_APPID}", "pt_feedback_link": f"https://support.qq.com/products/77942?customInfo=.appid{OPEN_APPID}",
"theme": "2", "verify_theme": "", "theme": "2",
"verify_theme": "",
} }
xlogin_url = f"{XLOGIN}?{urlencode(xlogin_query)}" xlogin_url = f"{XLOGIN}?{urlencode(xlogin_query)}"
xlogin = client.request(xlogin_url, referer=show_url) xlogin = client.request(xlogin_url, referer=show_url)
@@ -207,9 +235,16 @@ def main() -> int:
raise RuntimeError("QQ 登录页未写入 pt_login_sig") raise RuntimeError("QQ 登录页未写入 pt_login_sig")
qr_query = { qr_query = {
"appid": PT_APPID, "e": "2", "l": "M", "s": "3", "d": "72", "v": "4", "appid": PT_APPID,
"t": str(secrets.randbelow(1_000_000) / 1_000_000), "daid": PT_DAID, "e": "2",
"pt_3rd_aid": OPEN_APPID, "u1": LOGIN_JUMP, "l": "M",
"s": "3",
"d": "72",
"v": "4",
"t": str(secrets.randbelow(1_000_000) / 1_000_000),
"daid": PT_DAID,
"pt_3rd_aid": OPEN_APPID,
"u1": LOGIN_JUMP,
} }
qr_url = f"{QR_SHOW}?{urlencode(qr_query)}" qr_url = f"{QR_SHOW}?{urlencode(qr_query)}"
image = client.request(qr_url, referer=xlogin_url) image = client.request(qr_url, referer=xlogin_url)
@@ -228,10 +263,23 @@ def main() -> int:
o1v_id = secrets.token_hex(16) o1v_id = secrets.token_hex(16)
while time.monotonic() < deadline: while time.monotonic() < deadline:
poll_query = { poll_query = {
"u1": LOGIN_JUMP, "ptqrtoken": str(ptqr_token(qrsig)), "ptredirect": "0", "h": "1", "t": "1", "u1": LOGIN_JUMP,
"g": "1", "from_ui": "1", "ptlang": "2052", "action": f"0-0-{int(time.time() * 1000)}", "ptqrtoken": str(ptqr_token(qrsig)),
"js_ver": "26071711", "js_type": "1", "login_sig": login_sig, "pt_uistyle": "40", "ptredirect": "0",
"aid": PT_APPID, "daid": PT_DAID, "pt_3rd_aid": OPEN_APPID, "o1vId": o1v_id, "h": "1",
"t": "1",
"g": "1",
"from_ui": "1",
"ptlang": "2052",
"action": f"0-0-{int(time.time() * 1000)}",
"js_ver": "26071711",
"js_type": "1",
"login_sig": login_sig,
"pt_uistyle": "40",
"aid": PT_APPID,
"daid": PT_DAID,
"pt_3rd_aid": OPEN_APPID,
"o1vId": o1v_id,
"pt_js_version": "c1987b96", "pt_js_version": "c1987b96",
} }
poll = client.request(f"{QR_POLL}?{urlencode(poll_query)}", referer=xlogin_url) poll = client.request(f"{QR_POLL}?{urlencode(poll_query)}", referer=xlogin_url)
@@ -252,7 +300,9 @@ def main() -> int:
check_sig = client.request(callback, referer=xlogin_url) check_sig = client.request(callback, referer=xlogin_url)
login_jump = check_sig.location() login_jump = check_sig.location()
if check_sig.status != 302 or not login_jump.startswith("https://graph.qq.com/oauth2.0/login_jump"): if check_sig.status != 302 or not login_jump.startswith(
"https://graph.qq.com/oauth2.0/login_jump"
):
raise RuntimeError("QQ check_sig 未跳转到 OAuth login_jump") raise RuntimeError("QQ check_sig 未跳转到 OAuth login_jump")
jump = client.request(login_jump, referer=callback) jump = client.request(login_jump, referer=callback)
if jump.status != 200: if jump.status != 200:
@@ -262,8 +312,10 @@ def main() -> int:
if not p_skey: if not p_skey:
raise RuntimeError("QQ check_sig 未写入 p_skey") raise RuntimeError("QQ check_sig 未写入 p_skey")
authorize = client.request( authorize = client.request(
"https://graph.qq.com/oauth2.0/authorize", method="POST", "https://graph.qq.com/oauth2.0/authorize",
body=urlencode(authorize_params(state, p_skey)).encode("utf-8"), referer=login_jump, method="POST",
body=urlencode(authorize_params(state, p_skey)).encode("utf-8"),
referer=login_jump,
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
) )
oauth_callback = authorize.location() oauth_callback = authorize.location()
@@ -276,12 +328,16 @@ def main() -> int:
if not cookies.get("openid") or not cookies.get("accesstoken"): if not cookies.get("openid") or not cookies.get("accesstoken"):
raise RuntimeError("YYB QQ OAuth 回调未写入 openid/accesstoken") raise RuntimeError("YYB QQ OAuth 回调未写入 openid/accesstoken")
login_type = cookies.get("logintype", "QC") login_type = cookies.get("logintype", "QC")
info = client.request(USER_INFO, headers={ info = client.request(
"Ual-Access-Login-Type": login_type_header(login_type), USER_INFO,
"Ual-Access-Access-Token": cookies["accesstoken"], headers={
"Ual-Access-Openid": cookies["openid"], "Ual-Access-Login-Type": login_type_header(login_type),
"Origin": "https://m.yyb.qq.com", "Referer": "https://m.yyb.qq.com/", "Ual-Access-Access-Token": cookies["accesstoken"],
}) "Ual-Access-Openid": cookies["openid"],
"Origin": "https://m.yyb.qq.com",
"Referer": "https://m.yyb.qq.com/",
},
)
if info.status != 200: if info.status != 200:
raise RuntimeError(f"YYB QQ 登录态校验失败: HTTP {info.status}") raise RuntimeError(f"YYB QQ 登录态校验失败: HTTP {info.status}")
try: try:
@@ -4,6 +4,7 @@
二维码由微信 OAuth 生成用户用微信扫码确认本脚本轮询授权结果完成 二维码由微信 OAuth 生成用户用微信扫码确认本脚本轮询授权结果完成
YYB OAuth 回调后将动态 cookies 合并到 mall-session.json供后续纯 CK 流程使用 YYB OAuth 回调后将动态 cookies 合并到 mall-session.json供后续纯 CK 流程使用
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@@ -18,7 +19,12 @@ from http.cookiejar import CookieJar
from pathlib import Path from pathlib import Path
from urllib.error import HTTPError from urllib.error import HTTPError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import HTTPRedirectHandler, HTTPCookieProcessor, Request, build_opener from urllib.request import (
HTTPRedirectHandler,
HTTPCookieProcessor,
Request,
build_opener,
)
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
OPEN_APPID = "wxd44977328b36e647" OPEN_APPID = "wxd44977328b36e647"
@@ -58,7 +64,9 @@ class Client:
self.jar = CookieJar() self.jar = CookieJar()
self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar)) self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar))
def request(self, url: str, *, referer: str = "", headers: dict[str, str] | None = None) -> Response: def request(
self, url: str, *, referer: str = "", headers: dict[str, str] | None = None
) -> Response:
request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"} request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
if referer: if referer:
request_headers["Referer"] = referer request_headers["Referer"] = referer
@@ -116,7 +124,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None:
document["login_type"] = cookies.get("logintype", "WX") document["login_type"] = cookies.get("logintype", "WX")
document["login_updated_at"] = int(time.time()) document["login_updated_at"] = int(time.time())
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".mall-session-", suffix=".tmp", dir=path.parent) fd, temporary = tempfile.mkstemp(
prefix=".mall-session-", suffix=".tmp", dir=path.parent
)
try: try:
with os.fdopen(fd, "w", encoding="utf-8") as handle: with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(document, handle, ensure_ascii=False, indent=2) json.dump(document, handle, ensure_ascii=False, indent=2)
@@ -133,7 +143,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="应用宝微信扫码登录(纯 Python") parser = argparse.ArgumentParser(description="应用宝微信扫码登录(纯 Python")
parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json") parser.add_argument(
"--session", type=Path, default=ROOT / "config/mall-session.json"
)
parser.add_argument("--qr", type=Path, default=ROOT / "config/wechat-login.jpg") parser.add_argument("--qr", type=Path, default=ROOT / "config/wechat-login.jpg")
parser.add_argument("--timeout", type=float, default=300, help="二维码等待秒数") parser.add_argument("--timeout", type=float, default=300, help="二维码等待秒数")
parser.add_argument("--interval", type=float, default=2, help="轮询间隔秒数") parser.add_argument("--interval", type=float, default=2, help="轮询间隔秒数")
@@ -158,7 +170,9 @@ def main() -> int:
if page.status != 200: if page.status != 200:
raise RuntimeError(f"微信授权页请求失败: HTTP {page.status}") raise RuntimeError(f"微信授权页请求失败: HTTP {page.status}")
uuid = extract_uuid(page.text) uuid = extract_uuid(page.text)
image = client.request(f"https://open.weixin.qq.com/connect/qrcode/{uuid}", referer=authorization_url) image = client.request(
f"https://open.weixin.qq.com/connect/qrcode/{uuid}", referer=authorization_url
)
if image.status != 200 or not image.body: if image.status != 200 or not image.body:
raise RuntimeError(f"微信二维码请求失败: HTTP {image.status}") raise RuntimeError(f"微信二维码请求失败: HTTP {image.status}")
args.qr.parent.mkdir(parents=True, exist_ok=True) args.qr.parent.mkdir(parents=True, exist_ok=True)
@@ -173,7 +187,9 @@ def main() -> int:
poll_query = {"uuid": uuid} poll_query = {"uuid": uuid}
if last: if last:
poll_query["last"] = last poll_query["last"] = last
poll = client.request(f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url) poll = client.request(
f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url
)
errcode, code = parse_poll(poll.text) errcode, code = parse_poll(poll.text)
if errcode == 405 and code: if errcode == 405 and code:
break break
@@ -202,13 +218,16 @@ def main() -> int:
login_type = cookies.get("logintype", "WX") login_type = cookies.get("logintype", "WX")
if not openid or not access_token: if not openid or not access_token:
raise RuntimeError("YYB OAuth 回调未写入 openid/accesstoken") raise RuntimeError("YYB OAuth 回调未写入 openid/accesstoken")
info = client.request(USER_INFO, headers={ info = client.request(
"Ual-Access-Login-Type": login_type_header(login_type), USER_INFO,
"Ual-Access-Access-Token": access_token, headers={
"Ual-Access-Openid": openid, "Ual-Access-Login-Type": login_type_header(login_type),
"Origin": "https://m.yyb.qq.com", "Ual-Access-Access-Token": access_token,
"Referer": "https://m.yyb.qq.com/", "Ual-Access-Openid": openid,
}) "Origin": "https://m.yyb.qq.com",
"Referer": "https://m.yyb.qq.com/",
},
)
if info.status != 200: if info.status != 200:
raise RuntimeError(f"YYB 登录态校验失败: HTTP {info.status}") raise RuntimeError(f"YYB 登录态校验失败: HTTP {info.status}")
try: try:
@@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""用已登录的 YYB CK 选择和平精英点券档位、区服和角色。""" """用已登录的 YYB CK 选择和平精英点券档位、区服和角色。"""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@@ -45,26 +46,43 @@ UA = (
def load_cookies(path: Path) -> dict[str, str]: def load_cookies(path: Path) -> dict[str, str]:
session = json.loads(path.read_text(encoding="utf-8")) session = json.loads(path.read_text(encoding="utf-8"))
cookies = {str(key): str(value) for key, value in session.get("cookies", {}).items() if value} cookies = {
str(key): str(value)
for key, value in session.get("cookies", {}).items()
if value
}
if not cookies.get("openid") or not cookies.get("accesstoken"): if not cookies.get("openid") or not cookies.get("accesstoken"):
raise ValueError("会话缺少 openid/accesstoken,请先执行 login-wechat.py") raise ValueError("会话缺少 openid/accesstoken,请先执行 login-wechat.py")
return cookies return cookies
def product_options(cookies: dict[str, str], platform: str) -> list[dict[str, str | int]]: def product_options(
cookies: dict[str, str], platform: str
) -> list[dict[str, str | int]]:
response = requests.post( response = requests.post(
PRODUCTS_URL, PRODUCTS_URL,
json={"platform": PLATFORMS[platform]["query_platform"], "source_id": SOURCE_ID, json={
"yyb_app_id": YYB_APP_ID}, "platform": PLATFORMS[platform]["query_platform"],
headers={"Accept": "application/json, text/plain, */*", "Origin": "https://m.yyb.qq.com", "source_id": SOURCE_ID,
"Referer": "https://m.yyb.qq.com/boc-mall/goods-mall/", "User-Agent": UA}, "yyb_app_id": YYB_APP_ID,
cookies=cookies, impersonate="chrome", timeout=30, },
headers={
"Accept": "application/json, text/plain, */*",
"Origin": "https://m.yyb.qq.com",
"Referer": "https://m.yyb.qq.com/boc-mall/goods-mall/",
"User-Agent": UA,
},
cookies=cookies,
impersonate="chrome",
timeout=30,
) )
if response.status_code != 200: if response.status_code != 200:
raise RuntimeError(f"点券商品查询失败: HTTP {response.status_code}") raise RuntimeError(f"点券商品查询失败: HTTP {response.status_code}")
document = response.json() document = response.json()
if document.get("code") not in (None, 0): if document.get("code") not in (None, 0):
raise RuntimeError(f"点券商品查询失败: {document.get('code')} {document.get('message', '')}") raise RuntimeError(
f"点券商品查询失败: {document.get('code')} {document.get('message', '')}"
)
products = document.get("token_mod", {}).get("products", []) products = document.get("token_mod", {}).get("products", [])
result: list[dict[str, str | int]] = [] result: list[dict[str, str | int]] = []
for item in products: for item in products:
@@ -72,15 +90,19 @@ def product_options(cookies: dict[str, str], platform: str) -> list[dict[str, st
match = re.fullmatch(r"(\d+)点券", str(product.get("product_name", ""))) match = re.fullmatch(r"(\d+)点券", str(product.get("product_name", "")))
if not match or str(product.get("status")) != "20": if not match or str(product.get("status")) != "20":
continue continue
result.append({ result.append(
"points": int(match.group(1)), {
"product_id": str(product.get("product_id", "")), "points": int(match.group(1)),
"price_fen": int(product.get("price", 0)), "product_id": str(product.get("product_id", "")),
"offer_id": str(product.get("res_offer_id", "")), "price_fen": int(product.get("price", 0)),
"name": str(product.get("product_name", "")), "offer_id": str(product.get("res_offer_id", "")),
}) "name": str(product.get("product_name", "")),
}
)
result.sort(key=lambda item: int(item["points"])) result.sort(key=lambda item: int(item["points"]))
if not result or any(not item["product_id"] or not item["offer_id"] for item in result): if not result or any(
not item["product_id"] or not item["offer_id"] for item in result
):
raise RuntimeError("点券商品响应缺少 product_id 或 offer_id") raise RuntimeError("点券商品响应缺少 product_id 或 offer_id")
return result return result
@@ -95,30 +117,48 @@ class Cmall:
def query(self, cmd: str, **extra: str) -> dict: def query(self, cmd: str, **extra: str) -> dict:
login = midas_login_params(self.cookies) login = midas_login_params(self.cookies)
params = { params = {
"from_h5": "1", "pf": PLATFORMS[self.platform]["cmall_pf"], "r": str(random.random()), "cmd": cmd, "from_h5": "1",
"pf": PLATFORMS[self.platform]["cmall_pf"],
"r": str(random.random()),
"cmd": cmd,
"session_token": self.session_token, "session_token": self.session_token,
"pfkey": "pfkey", "webversion": "", **extra, "pfkey": "pfkey",
"webversion": "",
**extra,
**login, **login,
} }
# 当前商城页面将查询参数放在 URL 上,但请求方法为 POST 且没有 body。 # 当前商城页面将查询参数放在 URL 上,但请求方法为 POST 且没有 body。
response = requests.post( response = requests.post(
CMALL_URL.format(offer_id=self.offer_id) + "?" + urlencode(params), CMALL_URL.format(offer_id=self.offer_id) + "?" + urlencode(params),
headers={"Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/", headers={
"User-Agent": UA}, "Origin": "https://z.iwan.yyb.qq.com",
cookies=self.cookies, impersonate="chrome", timeout=30, "Referer": "https://z.iwan.yyb.qq.com/",
"User-Agent": UA,
},
cookies=self.cookies,
impersonate="chrome",
timeout=30,
) )
if response.status_code != 200: if response.status_code != 200:
raise RuntimeError(f"游戏数据查询失败: HTTP {response.status_code}") raise RuntimeError(f"游戏数据查询失败: HTTP {response.status_code}")
document = response.json() document = response.json()
if document.get("ret") not in (0, "0"): if document.get("ret") not in (0, "0"):
raise RuntimeError(f"游戏数据查询失败: {document.get('ret')} {document.get('msg', '')}") raise RuntimeError(
f"游戏数据查询失败: {document.get('ret')} {document.get('msg', '')}"
)
return document return document
def zones(self) -> list[dict[str, str]]: def zones(self) -> list[dict[str, str]]:
response = self.query("14", use_currency_offerid="1") response = self.query("14", use_currency_offerid="1")
zones = response.get("zone_list", []) zones = response.get("zone_list", [])
return [{"zone_id": str(item.get("zone_id", "")), "name": str(item.get("zone_name", ""))} return [
for item in zones if isinstance(item, dict) and item.get("zone_id")] {
"zone_id": str(item.get("zone_id", "")),
"name": str(item.get("zone_name", "")),
}
for item in zones
if isinstance(item, dict) and item.get("zone_id")
]
def roles(self, zone_id: str) -> list[dict[str, str]]: def roles(self, zone_id: str) -> list[dict[str, str]]:
response = self.query("15", use_currency_offerid="1", zoneid=zone_id) response = self.query("15", use_currency_offerid="1", zoneid=zone_id)
@@ -126,14 +166,18 @@ class Cmall:
# QQ 平台和平精英的 area 与 zoneid 不同(如 area=2, zoneid=1) # QQ 平台和平精英的 area 与 zoneid 不同(如 area=2, zoneid=1)
# PlaceOrder 校验角色时需要该分区信息,随角色一并返回。 # PlaceOrder 校验角色时需要该分区信息,随角色一并返回。
partition = response.get("partition_info") or {} partition = response.get("partition_info") or {}
return [{ return [
"role_id": str(item.get("role_id", "")), {
"name": unquote(str(item.get("role_name", ""))), "role_id": str(item.get("role_id", "")),
"ban_status": str(item.get("ban_status", "")), "name": unquote(str(item.get("role_name", ""))),
"area": str(partition.get("area", "")), "ban_status": str(item.get("ban_status", "")),
"partition": str(partition.get("partition", "")), "area": str(partition.get("area", "")),
"platid": str(partition.get("platid", "")), "partition": str(partition.get("partition", "")),
} for item in roles if isinstance(item, dict) and item.get("role_id")] "platid": str(partition.get("platid", "")),
}
for item in roles
if isinstance(item, dict) and item.get("role_id")
]
def choose(label: str, options: list[dict], display) -> dict: def choose(label: str, options: list[dict], display) -> dict:
@@ -151,18 +195,31 @@ def choose(label: str, options: list[dict], display) -> dict:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="和平精英点券和角色选择(纯 CK 查询)") parser = argparse.ArgumentParser(description="和平精英点券和角色选择(纯 CK 查询)")
parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json") parser.add_argument(
parser.add_argument("--output", type=Path, default=ROOT / "config/peace-elite-selection.json") "--session", type=Path, default=ROOT / "config/mall-session.json"
parser.add_argument("--platform", choices=tuple(PLATFORMS), default=None, )
help="预选 Android/iOS;不传则显示平台菜单") parser.add_argument(
parser.add_argument("--points", type=int, default=None, help="预选点券数;不传则显示菜单") "--output", type=Path, default=ROOT / "config/peace-elite-selection.json"
parser.add_argument("--list-products", action="store_true", help="仅列出当前所有点券档位") )
parser.add_argument(
"--platform",
choices=tuple(PLATFORMS),
default=None,
help="预选 Android/iOS;不传则显示平台菜单",
)
parser.add_argument(
"--points", type=int, default=None, help="预选点券数;不传则显示菜单"
)
parser.add_argument(
"--list-products", action="store_true", help="仅列出当前所有点券档位"
)
args = parser.parse_args() args = parser.parse_args()
cookies = load_cookies(args.session) cookies = load_cookies(args.session)
if args.platform is None: if args.platform is None:
selected_platform = choose( selected_platform = choose(
"平台", [{"id": key, **value} for key, value in PLATFORMS.items()], "平台",
[{"id": key, **value} for key, value in PLATFORMS.items()],
lambda item: item["label"], lambda item: item["label"],
) )
platform = str(selected_platform["id"]) platform = str(selected_platform["id"])
@@ -172,32 +229,59 @@ def main() -> int:
products = product_options(cookies, platform) products = product_options(cookies, platform)
if args.list_products: if args.list_products:
for product in products: for product in products:
print(f"{product['points']}点券\t{product['price_fen'] / 100:g}\t{product['product_id']}") print(
f"{product['points']}点券\t{product['price_fen'] / 100:g}\t{product['product_id']}"
)
return 0 return 0
if args.points is None: if args.points is None:
product = choose("点券档位", products, lambda item: f"{item['points']}点券({item['price_fen'] / 100:g}元)") product = choose(
"点券档位",
products,
lambda item: f"{item['points']}点券({item['price_fen'] / 100:g}元)",
)
else: else:
product = next((item for item in products if item["points"] == args.points), None) product = next(
(item for item in products if item["points"] == args.points), None
)
if product is None: if product is None:
available = ", ".join(str(item["points"]) for item in products) available = ", ".join(str(item["points"]) for item in products)
raise ValueError(f"不支持 {args.points} 点券;当前可选: {available}") raise ValueError(f"不支持 {args.points} 点券;当前可选: {available}")
print(f"已选择: {product['points']}点券({product['price_fen'] / 100:g}元)") print(f"已选择: {product['points']}点券({product['price_fen'] / 100:g}元)")
cmall = Cmall(cookies, str(product["offer_id"]), platform) cmall = Cmall(cookies, str(product["offer_id"]), platform)
zone = choose("区服", cmall.zones(), lambda item: f"{item['name']}ID {item['zone_id']}") zone = choose(
"区服", cmall.zones(), lambda item: f"{item['name']}ID {item['zone_id']}"
)
roles = cmall.roles(zone["zone_id"]) roles = cmall.roles(zone["zone_id"])
role = choose("角色", roles, lambda item: f"{item['name']}{'禁用' if item['ban_status'] == '1' else '可用'}") role = choose(
"角色",
roles,
lambda item: (
f"{item['name']}{'禁用' if item['ban_status'] == '1' else '可用'}"
),
)
if role["ban_status"] == "1": if role["ban_status"] == "1":
raise RuntimeError("所选角色已被封禁,不能充值") raise RuntimeError("所选角色已被封禁,不能充值")
selection = {"platform": platform, "order_pf": PLATFORMS[platform]["order_pf"], selection = {
"points": product["points"], "product_id": product["product_id"], "platform": platform,
"offer_id": product["offer_id"], "price_fen": product["price_fen"], "order_pf": PLATFORMS[platform]["order_pf"],
"zone_id": zone["zone_id"], "zone_name": zone["name"], "points": product["points"],
"role_id": role["role_id"], "role_name": role["name"]} "product_id": product["product_id"],
"offer_id": product["offer_id"],
"price_fen": product["price_fen"],
"zone_id": zone["zone_id"],
"zone_name": zone["name"],
"role_id": role["role_id"],
"role_name": role["name"],
}
args.output.parent.mkdir(parents=True, exist_ok=True) args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(selection, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") args.output.write_text(
print(f"\n已选择 {selection['points']}点券 / {selection['zone_name']} / {selection['role_name']}") json.dumps(selection, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(
f"\n已选择 {selection['points']}点券 / {selection['zone_name']} / {selection['role_name']}"
)
print(f"选择已保存: {args.output}") print(f"选择已保存: {args.output}")
return 0 return 0
+274 -81
View File
@@ -5,6 +5,7 @@ The worker owns per-job sessions and invokes the already verified protocol
scripts. It intentionally exposes QR images and state only; cookies and raw scripts. It intentionally exposes QR images and state only; cookies and raw
payment links never leave the worker API. payment links never leave the worker API.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@@ -79,15 +80,23 @@ def _safe_log(job: dict, line: str) -> None:
clean = re.sub(r"weixin://\S+", "[付款链接已隐藏]", line) clean = re.sub(r"weixin://\S+", "[付款链接已隐藏]", line)
clean = re.sub(r"HOLD_[A-Za-z0-9_-]+", "[订单已隐藏]", clean) clean = re.sub(r"HOLD_[A-Za-z0-9_-]+", "[订单已隐藏]", clean)
clean = re.sub(r"(订单\s*[:]\s*)\S+", r"\1[订单已隐藏]", clean) clean = re.sub(r"(订单\s*[:]\s*)\S+", r"\1[订单已隐藏]", clean)
clean = re.sub(r"(?:token|openid|openkey|cookie)=\S+", "[敏感字段已隐藏]", clean, flags=re.I) clean = re.sub(
clean = re.sub(r"(?:pay_token|web_token|anti_token|session_id|sessionid)\s*[:= ]\s*\S+", r"(?:token|openid|openkey|cookie)=\S+", "[敏感字段已隐藏]", clean, flags=re.I
"[敏感字段已隐藏]", clean, flags=re.I) )
clean = re.sub(
r"(?:pay_token|web_token|anti_token|session_id|sessionid)\s*[:= ]\s*\S+",
"[敏感字段已隐藏]",
clean,
flags=re.I,
)
timestamp = time.strftime("%H:%M:%S") timestamp = time.strftime("%H:%M:%S")
with _lock: with _lock:
job["logs"] = (job.get("logs", []) + [f"[{timestamp}] {clean.strip()}"])[-100:] job["logs"] = (job.get("logs", []) + [f"[{timestamp}] {clean.strip()}"])[-100:]
def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool = True) -> int: def _run_process(
job_id: str, command: list[str], phase: str, mark_success: bool = True
) -> int:
"""Run one stage process and return its exit code. """Run one stage process and return its exit code.
When ``mark_success`` is False the caller owns the post-success state When ``mark_success`` is False the caller owns the post-success state
@@ -104,16 +113,24 @@ def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool
stage_started = time.monotonic() stage_started = time.monotonic()
_safe_log(job, f"[{phase}] 开始执行") _safe_log(job, f"[{phase}] 开始执行")
try: try:
process = subprocess.Popen(command, cwd=ROOT, stdout=subprocess.PIPE, process = subprocess.Popen(
stderr=subprocess.STDOUT, text=True, command,
bufsize=1, env=environment) cwd=ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env=environment,
)
with _lock: with _lock:
job["process_pid"] = process.pid job["process_pid"] = process.pid
assert process.stdout is not None assert process.stdout is not None
for line in process.stdout: for line in process.stdout:
_safe_log(job, line) _safe_log(job, line)
code = process.wait() code = process.wait()
_safe_log(job, f"[{phase}] 执行结束,耗时 {time.monotonic() - stage_started:.1f}") _safe_log(
job, f"[{phase}] 执行结束,耗时 {time.monotonic() - stage_started:.1f}"
)
with _lock: with _lock:
job["process_pid"] = None job["process_pid"] = None
if code != 0: if code != 0:
@@ -134,7 +151,9 @@ def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool
job["message"] = "付款流程已完成" job["message"] = "付款流程已完成"
return code return code
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
_safe_log(job, f"[{phase}] 执行异常,耗时 {time.monotonic() - stage_started:.1f}") _safe_log(
job, f"[{phase}] 执行异常,耗时 {time.monotonic() - stage_started:.1f}"
)
with _lock: with _lock:
job["status"] = "failed" job["status"] = "failed"
job["message"] = str(exc) job["message"] = str(exc)
@@ -146,42 +165,74 @@ def _start_login(job_id: str, provider: str, timeout: int) -> None:
directory = _job_dir(job_id) directory = _job_dir(job_id)
session = directory / "mall-session.json" session = directory / "mall-session.json"
qr = directory / ("qq-login.jpg" if provider == "qq" else "wechat-login.jpg") qr = directory / ("qq-login.jpg" if provider == "qq" else "wechat-login.jpg")
command = [sys.executable, f"scripts/login-{provider}.py", "--session", str(session), command = [
"--qr", str(qr), "--timeout", str(timeout)] sys.executable,
f"scripts/login-{provider}.py",
"--session",
str(session),
"--qr",
str(qr),
"--timeout",
str(timeout),
]
with _lock: with _lock:
job["provider"] = provider job["provider"] = provider
job["qr_path"] = str(qr) job["qr_path"] = str(qr)
job["session_path"] = str(session) job["session_path"] = str(session)
job["status"] = "waiting_login" job["status"] = "waiting_login"
job["phase"] = "login" job["phase"] = "login"
threading.Thread(target=_run_process, args=(job_id, command, "login"), daemon=True).start() threading.Thread(
target=_run_process, args=(job_id, command, "login"), daemon=True
).start()
def _selection_options(job_id: str, platform: str, points: int | None, zone_id: str | None = None) -> dict: def _selection_options(
job_id: str, platform: str, points: int | None, zone_id: str | None = None
) -> dict:
if job_id not in _jobs: if job_id not in _jobs:
raise ValueError("任务不存在") raise ValueError("任务不存在")
selector = _load_selector() selector = _load_selector()
session = json.loads((_job_dir(job_id) / "mall-session.json").read_text(encoding="utf-8")) session = json.loads(
(_job_dir(job_id) / "mall-session.json").read_text(encoding="utf-8")
)
cookies = session.get("cookies", {}) cookies = session.get("cookies", {})
products = selector.product_options(cookies, platform) products = selector.product_options(cookies, platform)
if points is not None and not any(int(item["points"]) == points for item in products): if points is not None and not any(
int(item["points"]) == points for item in products
):
raise ValueError("当前登录态不支持该点券档位") raise ValueError("当前登录态不支持该点券档位")
product = next((item for item in products if int(item["points"]) == points), None) if points else None product = (
next((item for item in products if int(item["points"]) == points), None)
if points
else None
)
if product is None: if product is None:
product = products[0] product = products[0]
cmall = selector.Cmall(cookies, str(product["offer_id"]), platform) cmall = selector.Cmall(cookies, str(product["offer_id"]), platform)
zones = cmall.zones() zones = cmall.zones()
selected_zone = next((zone for zone in zones if str(zone["zone_id"]) == str(zone_id)), None) selected_zone = next(
(zone for zone in zones if str(zone["zone_id"]) == str(zone_id)), None
)
if zone_id and selected_zone is None: if zone_id and selected_zone is None:
raise ValueError("区服不存在") raise ValueError("区服不存在")
selected_zone = selected_zone or (zones[0] if zones else None) selected_zone = selected_zone or (zones[0] if zones else None)
roles = cmall.roles(selected_zone["zone_id"]) if selected_zone else [] roles = cmall.roles(selected_zone["zone_id"]) if selected_zone else []
return {"products": products, "zones": zones, "roles": roles, return {
"default_product": product, "default_zone": selected_zone} "products": products,
"zones": zones,
"roles": roles,
"default_product": product,
"default_zone": selected_zone,
}
def _payment_stage(job_id: str, command: list[str], phase: str, running_message: str, def _payment_stage(
failed_message: str) -> bool: job_id: str,
command: list[str],
phase: str,
running_message: str,
failed_message: str,
) -> bool:
"""Run one payment stage; return True on success without touching final state.""" """Run one payment stage; return True on success without touching final state."""
job = _jobs[job_id] job = _jobs[job_id]
with _lock: with _lock:
@@ -210,18 +261,32 @@ def _check_payment_once(job_id: str) -> int:
"""Read-only completion check; returns 0=confirmed, 1=not yet, 2=check failed.""" """Read-only completion check; returns 0=confirmed, 1=not yet, 2=check failed."""
job = _jobs[job_id] job = _jobs[job_id]
directory = _job_dir(job_id) directory = _job_dir(job_id)
command = [sys.executable, "scripts/jsdom-pay.py", "--check-only", command = [
"--session", str(directory / "mall-session.json"), sys.executable,
"--out-dir", str(directory / "jsdom-order")] "scripts/jsdom-pay.py",
"--check-only",
"--session",
str(directory / "mall-session.json"),
"--out-dir",
str(directory / "jsdom-order"),
]
environment = os.environ.copy() environment = os.environ.copy()
environment.pop("NODE_OPTIONS", None) environment.pop("NODE_OPTIONS", None)
check_started = time.monotonic() check_started = time.monotonic()
_safe_log(job, "[到账检测] 开始执行") _safe_log(job, "[到账检测] 开始执行")
try: try:
result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, result = subprocess.run(
env=environment, timeout=90) command,
cwd=ROOT,
capture_output=True,
text=True,
env=environment,
timeout=90,
)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
_safe_log(job, f"[到账检测] 执行超时,耗时 {time.monotonic() - check_started:.1f}") _safe_log(
job, f"[到账检测] 执行超时,耗时 {time.monotonic() - check_started:.1f}"
)
with _lock: with _lock:
job["payment_last_checked_at"] = int(time.time()) job["payment_last_checked_at"] = int(time.time())
return 2 return 2
@@ -229,7 +294,9 @@ def _check_payment_once(job_id: str) -> int:
_safe_log(job, line) _safe_log(job, line)
for line in (result.stderr or "").splitlines(): for line in (result.stderr or "").splitlines():
_safe_log(job, line) _safe_log(job, line)
_safe_log(job, f"[到账检测] 执行结束,耗时 {time.monotonic() - check_started:.1f}") _safe_log(
job, f"[到账检测] 执行结束,耗时 {time.monotonic() - check_started:.1f}"
)
with _lock: with _lock:
job["payment_last_checked_at"] = int(time.time()) job["payment_last_checked_at"] = int(time.time())
return result.returncode return result.returncode
@@ -275,28 +342,62 @@ def _payment_flow(job_id: str, selection: dict) -> None:
job["status"] = "ordering" job["status"] = "ordering"
job["phase"] = "payment" job["phase"] = "payment"
job["message"] = "正在创建商城订单" job["message"] = "正在创建商城订单"
order_cmd = [sys.executable, "main.py", "mall", "auto", "--session", str(session), order_cmd = [
"--order-template", str(ROOT / "config" / "mall-order-template.json"), sys.executable,
"--quantity", "1", "--product-id", str(selection["product_id"]), "main.py",
"--offer-id", str(selection["offer_id"]), "mall",
"--role-id", str(selection["role_id"]), "--role-name", str(selection["role_name"]), "auto",
"--zone-id", str(selection["zone_id"]), "--zone-name", str(selection["zone_name"]), "--session",
"--output", str(response)] str(session),
"--order-template",
str(ROOT / "config" / "mall-order-template.json"),
"--quantity",
"1",
"--product-id",
str(selection["product_id"]),
"--offer-id",
str(selection["offer_id"]),
"--role-id",
str(selection["role_id"]),
"--role-name",
str(selection["role_name"]),
"--zone-id",
str(selection["zone_id"]),
"--zone-name",
str(selection["zone_name"]),
"--output",
str(response),
]
if selection.get("area"): if selection.get("area"):
order_cmd.extend(["--area", str(selection["area"])]) order_cmd.extend(["--area", str(selection["area"])])
if selection.get("partition"): if selection.get("partition"):
order_cmd.extend(["--partition", str(selection["partition"])]) order_cmd.extend(["--partition", str(selection["partition"])])
if selection.get("order_pf"): if selection.get("order_pf"):
order_cmd.extend(["--pf", str(selection["order_pf"])]) order_cmd.extend(["--pf", str(selection["order_pf"])])
if not _payment_stage(job_id, order_cmd, "order", "正在创建商城订单", "创建商城订单失败"): if not _payment_stage(
job_id, order_cmd, "order", "正在创建商城订单", "创建商城订单失败"
):
return return
pay_cmd = [sys.executable, "scripts/jsdom-pay.py", "--session", str(session), pay_cmd = [
"--mall-response", str(response), "--out-dir", str(output), sys.executable,
"--zone-id", str(selection["zone_id"]), "scripts/jsdom-pay.py",
"--pf", str(selection.get("order_pf", "")), "--session",
"--amount-fen", str(selection["price_fen"]), str(session),
"--skip-payment-check"] "--mall-response",
if not _payment_stage(job_id, pay_cmd, "payment", "正在生成微信付款码", "生成付款码失败"): str(response),
"--out-dir",
str(output),
"--zone-id",
str(selection["zone_id"]),
"--pf",
str(selection.get("order_pf", "")),
"--amount-fen",
str(selection["price_fen"]),
"--skip-payment-check",
]
if not _payment_stage(
job_id, pay_cmd, "payment", "正在生成微信付款码", "生成付款码失败"
):
return return
meta_path = output / "payment-meta.json" meta_path = output / "payment-meta.json"
try: try:
@@ -318,7 +419,9 @@ def _start_payment(job_id: str, selection: dict) -> None:
_jobs[job_id]["status"] = "ordering" _jobs[job_id]["status"] = "ordering"
_jobs[job_id]["phase"] = "payment" _jobs[job_id]["phase"] = "payment"
_jobs[job_id]["message"] = "正在创建商城订单" _jobs[job_id]["message"] = "正在创建商城订单"
threading.Thread(target=_payment_flow, args=(job_id, selection), daemon=True).start() threading.Thread(
target=_payment_flow, args=(job_id, selection), daemon=True
).start()
def _stop_job(job_id: str) -> None: def _stop_job(job_id: str) -> None:
@@ -344,13 +447,18 @@ def _stop_job(job_id: str) -> None:
def _public_job(job_id: str) -> dict: def _public_job(job_id: str) -> dict:
job = _jobs[job_id] job = _jobs[job_id]
result = {key: value for key, value in job.items() result = {
if key not in {"directory", "session_path", "process_pid"}} key: value
for key, value in job.items()
if key not in {"directory", "session_path", "process_pid"}
}
qr_path = job.get("qr_path", "") qr_path = job.get("qr_path", "")
if qr_path and Path(qr_path).exists(): if qr_path and Path(qr_path).exists():
qr_bytes = Path(qr_path).read_bytes() qr_bytes = Path(qr_path).read_bytes()
result["qr_data"] = base64.b64encode(qr_bytes).decode("ascii") result["qr_data"] = base64.b64encode(qr_bytes).decode("ascii")
result["qr_mime_type"] = "image/jpeg" if qr_bytes.startswith(b"\xff\xd8\xff") else "image/png" result["qr_mime_type"] = (
"image/jpeg" if qr_bytes.startswith(b"\xff\xd8\xff") else "image/png"
)
output = Path(job["directory"]) / "jsdom-order" output = Path(job["directory"]) / "jsdom-order"
for name in ("wechat-pay.png", "payment-status.json"): for name in ("wechat-pay.png", "payment-status.json"):
path = None path = None
@@ -359,18 +467,32 @@ def _public_job(job_id: str) -> dict:
path = direct if direct.exists() else next(output.glob(f"*/{name}"), None) path = direct if direct.exists() else next(output.glob(f"*/{name}"), None)
if path and name.endswith(".png"): if path and name.endswith(".png"):
payment_qr_bytes = path.read_bytes() payment_qr_bytes = path.read_bytes()
result["payment_qr_data"] = base64.b64encode(payment_qr_bytes).decode("ascii") result["payment_qr_data"] = base64.b64encode(payment_qr_bytes).decode(
result["payment_qr_mime_type"] = "image/jpeg" if payment_qr_bytes.startswith(b"\xff\xd8\xff") else "image/png" "ascii"
)
result["payment_qr_mime_type"] = (
"image/jpeg"
if payment_qr_bytes.startswith(b"\xff\xd8\xff")
else "image/png"
)
elif path: elif path:
try: try:
status = json.loads(path.read_text(encoding="utf-8")) status = json.loads(path.read_text(encoding="utf-8"))
matched = status.get("matched_completion") if isinstance(status, dict) else None matched = (
status.get("matched_completion")
if isinstance(status, dict)
else None
)
result["payment_status"] = { result["payment_status"] = {
"checked_at": status.get("checked_at") if isinstance(status, dict) else None, "checked_at": status.get("checked_at")
if isinstance(status, dict)
else None,
"matched_completion": { "matched_completion": {
"is_finished": matched.get("is_finished"), "is_finished": matched.get("is_finished"),
"status": matched.get("status"), "status": matched.get("status"),
} if isinstance(matched, dict) else None, }
if isinstance(matched, dict)
else None,
} }
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
pass pass
@@ -411,8 +533,14 @@ class Handler(BaseHTTPRequestHandler):
directory = DEFAULT_DATA / job_id directory = DEFAULT_DATA / job_id
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
os.chmod(directory, 0o700) os.chmod(directory, 0o700)
_jobs[job_id] = {"job_id": job_id, "status": "created", "phase": "login", _jobs[job_id] = {
"logs": [], "directory": str(directory), "created_at": int(time.time())} "job_id": job_id,
"status": "created",
"phase": "login",
"logs": [],
"directory": str(directory),
"created_at": int(time.time()),
}
return self._json(201, _public_job(job_id)) return self._json(201, _public_job(job_id))
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "login": if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "login":
job_id = path[2] job_id = path[2]
@@ -421,44 +549,87 @@ class Handler(BaseHTTPRequestHandler):
return self._json(400, {"detail": "无效任务或登录方式"}) return self._json(400, {"detail": "无效任务或登录方式"})
_start_login(job_id, body["provider"], int(body.get("timeout", 600))) _start_login(job_id, body["provider"], int(body.get("timeout", 600)))
return self._json(202, _public_job(job_id)) return self._json(202, _public_job(job_id))
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection-options": if (
len(path) == 4
and path[:2] == ["v1", "jobs"]
and path[3] == "selection-options"
):
job_id = path[2] job_id = path[2]
body = self._body() body = self._body()
options = _selection_options(job_id, str(body.get("platform", "android")), body.get("points"), body.get("zone_id")) options = _selection_options(
job_id,
str(body.get("platform", "android")),
body.get("points"),
body.get("zone_id"),
)
_jobs[job_id]["selection_options"] = options _jobs[job_id]["selection_options"] = options
return self._json(200, options) return self._json(200, options)
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection": if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection":
job_id = path[2] job_id = path[2]
body = self._body() body = self._body()
required = ("platform", "points", "product_id", "role_id", "role_name", "zone_id") required = (
"platform",
"points",
"product_id",
"role_id",
"role_name",
"zone_id",
)
if job_id not in _jobs or any(not body.get(key) for key in required): if job_id not in _jobs or any(not body.get(key) for key in required):
return self._json(400, {"detail": "选择参数不完整"}) return self._json(400, {"detail": "选择参数不完整"})
selector = _load_selector() selector = _load_selector()
if body["platform"] not in selector.PLATFORMS: if body["platform"] not in selector.PLATFORMS:
return self._json(400, {"detail": "不支持的平台"}) return self._json(400, {"detail": "不支持的平台"})
session_path = _job_dir(job_id) / "mall-session.json" session_path = _job_dir(job_id) / "mall-session.json"
cookies = json.loads(session_path.read_text(encoding="utf-8")).get("cookies", {}) cookies = json.loads(session_path.read_text(encoding="utf-8")).get(
product = next((item for item in selector.product_options(cookies, body["platform"]) "cookies", {}
if str(item["product_id"]) == str(body["product_id"]) )
and int(item["points"]) == int(body["points"])), None) product = next(
(
item
for item in selector.product_options(cookies, body["platform"])
if str(item["product_id"]) == str(body["product_id"])
and int(item["points"]) == int(body["points"])
),
None,
)
if product is None: if product is None:
return self._json(400, {"detail": "商品已失效,请重新选择"}) return self._json(400, {"detail": "商品已失效,请重新选择"})
cmall = selector.Cmall(cookies, str(product["offer_id"]), body["platform"]) cmall = selector.Cmall(
zone = next((item for item in cmall.zones() cookies, str(product["offer_id"]), body["platform"]
if str(item["zone_id"]) == str(body["zone_id"])), None) )
zone = next(
(
item
for item in cmall.zones()
if str(item["zone_id"]) == str(body["zone_id"])
),
None,
)
if zone is None: if zone is None:
return self._json(400, {"detail": "区服已失效,请重新选择"}) return self._json(400, {"detail": "区服已失效,请重新选择"})
role = next((item for item in cmall.roles(zone["zone_id"]) role = next(
if str(item["role_id"]) == str(body["role_id"])), None) (
item
for item in cmall.roles(zone["zone_id"])
if str(item["role_id"]) == str(body["role_id"])
),
None,
)
if role is None or role.get("ban_status") == "1": if role is None or role.get("ban_status") == "1":
return self._json(400, {"detail": "角色不可充值,请重新选择"}) return self._json(400, {"detail": "角色不可充值,请重新选择"})
_jobs[job_id]["selection"] = { _jobs[job_id]["selection"] = {
"platform": body["platform"], "points": product["points"], "platform": body["platform"],
"points": product["points"],
"price_fen": product["price_fen"], "price_fen": product["price_fen"],
"product_id": product["product_id"], "offer_id": product["offer_id"], "product_id": product["product_id"],
"zone_id": zone["zone_id"], "zone_name": zone["name"], "offer_id": product["offer_id"],
"role_id": role["role_id"], "role_name": role["name"], "zone_id": zone["zone_id"],
"area": role.get("area", ""), "partition": role.get("partition", ""), "zone_name": zone["name"],
"role_id": role["role_id"],
"role_name": role["name"],
"area": role.get("area", ""),
"partition": role.get("partition", ""),
"order_pf": selector.PLATFORMS[body["platform"]]["order_pf"], "order_pf": selector.PLATFORMS[body["platform"]]["order_pf"],
} }
_jobs[job_id]["phase"] = "payment" _jobs[job_id]["phase"] = "payment"
@@ -473,14 +644,23 @@ class Handler(BaseHTTPRequestHandler):
with _lock: with _lock:
if job.get("status") == "stopped": if job.get("status") == "stopped":
return self._json(400, {"detail": "任务已停止,不能生成付款码"}) return self._json(400, {"detail": "任务已停止,不能生成付款码"})
if job.get("status") in {"ordering", "waiting_payment", "payment_timeout"} \ if job.get("status") in {
or job.get("process_pid"): "ordering",
return self._json(400, {"detail": "已有进行中的支付流程,请勿重复操作"}) "waiting_payment",
"payment_timeout",
} or job.get("process_pid"):
return self._json(
400, {"detail": "已有进行中的支付流程,请勿重复操作"}
)
if not job.get("selection"): if not job.get("selection"):
return self._json(400, {"detail": "请先完成角色选择"}) return self._json(400, {"detail": "请先完成角色选择"})
_start_payment(job_id, job["selection"]) _start_payment(job_id, job["selection"])
return self._json(202, _public_job(job_id)) return self._json(202, _public_job(job_id))
if len(path) == 5 and path[:2] == ["v1", "jobs"] and path[3:5] == ["payment", "check"]: if (
len(path) == 5
and path[:2] == ["v1", "jobs"]
and path[3:5] == ["payment", "check"]
):
job_id = path[2] job_id = path[2]
if job_id not in _jobs: if job_id not in _jobs:
return self._json(400, {"detail": "任务不存在"}) return self._json(400, {"detail": "任务不存在"})
@@ -541,7 +721,9 @@ def _restore_jobs(data_dir: Path) -> int:
continue continue
if not meta_path.exists(): if not meta_path.exists():
_jobs[job_id] = { _jobs[job_id] = {
"job_id": job_id, "status": "ready", "phase": "selection", "job_id": job_id,
"status": "ready",
"phase": "selection",
"logs": ["服务重启,已从任务目录恢复登录会话"], "logs": ["服务重启,已从任务目录恢复登录会话"],
"directory": str(directory), "directory": str(directory),
"created_at": int(time.time()), "created_at": int(time.time()),
@@ -569,7 +751,9 @@ def _restore_jobs(data_dir: Path) -> int:
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
pass pass
_jobs[job_id] = { _jobs[job_id] = {
"job_id": job_id, "status": status, "phase": phase, "job_id": job_id,
"status": status,
"phase": phase,
"logs": ["服务重启,已从任务目录恢复本任务"], "logs": ["服务重启,已从任务目录恢复本任务"],
"directory": str(directory), "directory": str(directory),
"created_at": int(time.time()), "created_at": int(time.time()),
@@ -587,9 +771,15 @@ def main() -> int:
parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8810) parser.add_argument("--port", type=int, default=8810)
parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA) parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA)
parser.add_argument("--evidence-ttl-hours", type=int, default=DEFAULT_EVIDENCE_TTL_HOURS, parser.add_argument(
help="任务原始证据保留时长;0 表示不自动清理") "--evidence-ttl-hours",
parser.add_argument("--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY") type=int,
default=DEFAULT_EVIDENCE_TTL_HOURS,
help="任务原始证据保留时长;0 表示不自动清理",
)
parser.add_argument(
"--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY"
)
args = parser.parse_args() args = parser.parse_args()
# 强制绝对路径:子进程以 ROOT 为 cwd,相对路径会让任务文件写到错误位置。 # 强制绝对路径:子进程以 ROOT 为 cwd,相对路径会让任务文件写到错误位置。
DEFAULT_DATA = Path(args.data_dir).resolve() DEFAULT_DATA = Path(args.data_dir).resolve()
@@ -603,7 +793,10 @@ def main() -> int:
os.chmod(DEFAULT_DATA, 0o700) os.chmod(DEFAULT_DATA, 0o700)
removed = _cleanup_expired_jobs(DEFAULT_DATA, args.evidence_ttl_hours) removed = _cleanup_expired_jobs(DEFAULT_DATA, args.evidence_ttl_hours)
if removed: if removed:
print(f"已清理 {removed} 个过期任务证据(保留期 {args.evidence_ttl_hours} 小时)", flush=True) print(
f"已清理 {removed} 个过期任务证据(保留期 {args.evidence_ttl_hours} 小时)",
flush=True,
)
restored = _restore_jobs(DEFAULT_DATA) restored = _restore_jobs(DEFAULT_DATA)
if restored: if restored:
print(f"已从任务目录恢复 {restored} 个支付任务", flush=True) print(f"已从任务目录恢复 {restored} 个支付任务", flush=True)
@@ -1,4 +1,5 @@
"""YYB 付款表单的离线回归测试。""" """YYB 付款表单的离线回归测试。"""
from __future__ import annotations from __future__ import annotations
import importlib.util import importlib.util
@@ -23,14 +24,24 @@ from pyvm.protocol import goods_material_diagnostics, validate_goods_materials
class TestJsdomPay(unittest.TestCase): class TestJsdomPay(unittest.TestCase):
def test_build_save_body_has_current_payment_context(self): def test_build_save_body_has_current_payment_context(self):
body = _MODULE.build_save_body( body = _MODULE.build_save_body(
{"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"}, {
"token_id": "token",
"transaction_id": "transaction",
"out_trade_no": "trade",
},
{"openid": "openid", "accesstoken": "access-token", "logintype": "WX"}, {"openid": "openid", "accesstoken": "access-token", "logintype": "WX"},
"web-token", "anti-token", "cipher", "2", "web-token",
"mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap", 600, "anti-token",
"cipher",
"2",
"mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap",
600,
) )
fields = parse_qs(body, keep_blank_values=True) fields = parse_qs(body, keep_blank_values=True)
self.assertEqual(fields["type"], ["bg"]) self.assertEqual(fields["type"], ["bg"])
self.assertEqual(fields["pf"], ["mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap"]) self.assertEqual(
fields["pf"], ["mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap"]
)
self.assertEqual(fields["zoneid"], ["2"]) self.assertEqual(fields["zoneid"], ["2"])
self.assertEqual(fields["pay_method"], ["wechat"]) self.assertEqual(fields["pay_method"], ["wechat"])
self.assertEqual(fields["wcp"], ["type=CNY&amt=600"]) self.assertEqual(fields["wcp"], ["type=CNY&amt=600"])
@@ -39,9 +50,23 @@ class TestJsdomPay(unittest.TestCase):
def test_qq_payment_uses_qq_oauth_session_fields(self): def test_qq_payment_uses_qq_oauth_session_fields(self):
body = _MODULE.build_save_body( body = _MODULE.build_save_body(
{"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"}, {
{"openid": "openid", "accesstoken": "access-token", "logintype": "QC", "appid": "102033112"}, "token_id": "token",
"web-token", "anti-token", "cipher", "1", "pf", 100, "transaction_id": "transaction",
"out_trade_no": "trade",
},
{
"openid": "openid",
"accesstoken": "access-token",
"logintype": "QC",
"appid": "102033112",
},
"web-token",
"anti-token",
"cipher",
"1",
"pf",
100,
) )
fields = parse_qs(body, keep_blank_values=True) fields = parse_qs(body, keep_blank_values=True)
self.assertEqual(fields["session_id"], ["openid"]) self.assertEqual(fields["session_id"], ["openid"])
@@ -52,18 +77,38 @@ class TestJsdomPay(unittest.TestCase):
def test_wechat_payment_does_not_include_qq_offer_field(self): def test_wechat_payment_does_not_include_qq_offer_field(self):
body = _MODULE.build_save_body( body = _MODULE.build_save_body(
{"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"}, {
"token_id": "token",
"transaction_id": "transaction",
"out_trade_no": "trade",
},
{"openid": "openid", "accesstoken": "access-token", "logintype": "WX"}, {"openid": "openid", "accesstoken": "access-token", "logintype": "WX"},
"web-token", "anti-token", "cipher", "1", "pf", 100, "web-token",
"anti-token",
"cipher",
"1",
"pf",
100,
) )
fields = parse_qs(body, keep_blank_values=True) fields = parse_qs(body, keep_blank_values=True)
self.assertNotIn("offerid_for_qq_appid", fields) self.assertNotIn("offerid_for_qq_appid", fields)
def test_qq_page_info_matches_the_web_flow(self): def test_qq_page_info_matches_the_web_flow(self):
body = _MODULE.build_page_info_body( body = _MODULE.build_page_info_body(
{"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"}, {
{"openid": "openid", "accesstoken": "access-token", "logintype": "QC", "appid": "102033112"}, "token_id": "token",
"anti-token", "1", "pf", "transaction_id": "transaction",
"out_trade_no": "trade",
},
{
"openid": "openid",
"accesstoken": "access-token",
"logintype": "QC",
"appid": "102033112",
},
"anti-token",
"1",
"pf",
) )
fields = parse_qs(body, keep_blank_values=True) fields = parse_qs(body, keep_blank_values=True)
self.assertEqual(fields["isusempaymode"], ["1"]) self.assertEqual(fields["isusempaymode"], ["1"])
@@ -71,13 +116,17 @@ class TestJsdomPay(unittest.TestCase):
self.assertNotIn("pay_method", fields) self.assertNotIn("pay_method", fields)
def test_qq_risk_rejection_is_not_reported_as_qr_error(self): def test_qq_risk_rejection_is_not_reported_as_qr_error(self):
message = describe_payment_failure("QC", "payment", "ret=1099 err_code=1099-1007-0") message = describe_payment_failure(
"QC", "payment", "ret=1099 err_code=1099-1007-0"
)
self.assertIn("风控/限流", message) self.assertIn("风控/限流", message)
self.assertIn("未生成微信付款码", message) self.assertIn("未生成微信付款码", message)
self.assertIn("停止连续重试", message) self.assertIn("停止连续重试", message)
def test_wechat_rejection_is_not_labeled_as_risk_control(self): def test_wechat_rejection_is_not_labeled_as_risk_control(self):
message = describe_payment_failure("WX", "payment", "ret=1099 err_code=1099-1007-0") message = describe_payment_failure(
"WX", "payment", "ret=1099 err_code=1099-1007-0"
)
self.assertIn("服务端拒绝", message) self.assertIn("服务端拒绝", message)
self.assertNotIn("风控", message) self.assertNotIn("风控", message)
@@ -88,17 +137,30 @@ class TestJsdomPay(unittest.TestCase):
def test_qq_encrypt_rand_aligns_plaintext_to_aes_block(self): def test_qq_encrypt_rand_aligns_plaintext_to_aes_block(self):
params = { params = {
"token_id": "t", "openid": "o", "openkey": "k", "session_id": "openid", "token_id": "t",
"session_type": "kp_accesstoken", "zoneid": "1", "pay_method": "wechat", "openid": "o",
"buy_quantity": "1", "from_h5": "1", "webversion": "minipayv2", "openkey": "k",
"session_id": "openid",
"session_type": "kp_accesstoken",
"zoneid": "1",
"pay_method": "wechat",
"buy_quantity": "1",
"from_h5": "1",
"webversion": "minipayv2",
} }
rand_value = _MODULE.make_encrypt_rand(params, "tdrc_session%3Dpay-test", "1775990000", True) rand_value = _MODULE.make_encrypt_rand(
plaintext = _MODULE.build_plaintext(params, "tdrc_session%3Dpay-test", "1775990000", rand_value) params, "tdrc_session%3Dpay-test", "1775990000", True
)
plaintext = _MODULE.build_plaintext(
params, "tdrc_session%3Dpay-test", "1775990000", rand_value
)
self.assertEqual(len(plaintext.encode("latin-1")) % 16, 0) self.assertEqual(len(plaintext.encode("latin-1")) % 16, 0)
self.assertGreaterEqual(len(rand_value), 8) self.assertGreaterEqual(len(rand_value), 8)
def test_wechat_encrypt_rand_preserves_historical_control_byte(self): def test_wechat_encrypt_rand_preserves_historical_control_byte(self):
rand_value = _MODULE.make_encrypt_rand({}, "tdrc_session%3Dpay-test", "1775990000", False) rand_value = _MODULE.make_encrypt_rand(
{}, "tdrc_session%3Dpay-test", "1775990000", False
)
self.assertEqual(len(rand_value), 9) self.assertEqual(len(rand_value), 9)
self.assertEqual(rand_value[-1], "\x01") self.assertEqual(rand_value[-1], "\x01")
@@ -124,12 +186,26 @@ class TestJsdomPay(unittest.TestCase):
import json import json
template = _MODULE.load_template_args() template = _MODULE.load_template_args()
xmidas = json.loads((_MODULE.ROOT / "replay" / "xmidasops.json").read_text(encoding="utf-8")) xmidas = json.loads(
(_MODULE.ROOT / "replay" / "xmidasops.json").read_text(encoding="utf-8")
)
params = { params = {
"token_id": "token", "openid": "openid", "openkey": "key", "session_id": "openid", "token_id": "token",
"session_type": "kp_accesstoken", "zoneid": "1", "pay_method": "wechat", "openid": "openid",
"buy_quantity": "1", "mb_pwd": "", "pay_id": "", "auth_key": "", "card_value": "", "openkey": "key",
"accounttype": "", "provide_uin": "", "extend": "", "from_h5": "1", "session_id": "openid",
"session_type": "kp_accesstoken",
"zoneid": "1",
"pay_method": "wechat",
"buy_quantity": "1",
"mb_pwd": "",
"pay_id": "",
"auth_key": "",
"card_value": "",
"accounttype": "",
"provide_uin": "",
"extend": "",
"from_h5": "1",
"webversion": "minipayv2", "webversion": "minipayv2",
} }
timestamp = "1775990000" timestamp = "1775990000"
@@ -137,16 +213,27 @@ class TestJsdomPay(unittest.TestCase):
for length in range(512): for length in range(512):
params["extend"] = "x" * length params["extend"] = "x" * length
rand_value = _MODULE.make_encrypt_rand(params, fk_extend, timestamp, True) rand_value = _MODULE.make_encrypt_rand(params, fk_extend, timestamp, True)
plaintext = _MODULE.build_plaintext(params, fk_extend, timestamp, rand_value) plaintext = _MODULE.build_plaintext(
params, fk_extend, timestamp, rand_value
)
if len(plaintext.encode("latin-1")) == 528: if len(plaintext.encode("latin-1")) == 528:
break break
else: else:
self.fail("无法构造 528B goods 测试明文") self.fail("无法构造 528B goods 测试明文")
key16 = list(range(16)) key16 = list(range(16))
key1 = derive_key1_from_key16(key16, [template[i][0] for i in (1, 2, 3, 4)], template[5][0]) key1 = derive_key1_from_key16(
key16, [template[i][0] for i in (1, 2, 3, 4)], template[5][0]
)
ciphertext = generate_encrypt_msg_offline( ciphertext = generate_encrypt_msg_offline(
params, fk_extend, timestamp, rand_value, key16=key16, key1=key1, params,
args_template=template, xmidas=xmidas, xmidas_token="A" * 96, fk_extend,
timestamp,
rand_value,
key16=key16,
key1=key1,
args_template=template,
xmidas=xmidas,
xmidas_token="A" * 96,
) )
self.assertEqual(len(ciphertext), 1056) self.assertEqual(len(ciphertext), 1056)
+24 -8
View File
@@ -67,8 +67,12 @@ class _SensitiveDataFilter(logging.Filter):
@classmethod @classmethod
def redact(cls, message: str) -> str: def redact(cls, message: str) -> str:
message = cls._HEADER_RE.sub(lambda m: f"{m.group(1)}{m.group(2)} [REDACTED]", message) message = cls._HEADER_RE.sub(
message = cls._KEY_VALUE_RE.sub(lambda m: f"{m.group('prefix')}[REDACTED]", message) lambda m: f"{m.group(1)}{m.group(2)} [REDACTED]", message
)
message = cls._KEY_VALUE_RE.sub(
lambda m: f"{m.group('prefix')}[REDACTED]", message
)
message = cls._BEARER_RE.sub("Bearer [REDACTED]", message) message = cls._BEARER_RE.sub("Bearer [REDACTED]", message)
return cls._JWT_RE.sub("[REDACTED_JWT]", message) return cls._JWT_RE.sub("[REDACTED_JWT]", message)
@@ -105,7 +109,9 @@ class _SizeAndDayRotatingFileHandler(BaseRotatingHandler):
self._filename_prefix = re.sub(r"-\d{4}-\d{2}-\d{2}$", "", path.stem) self._filename_prefix = re.sub(r"-\d{4}-\d{2}-\d{2}$", "", path.stem)
def _path_for_day(self, day) -> Path: def _path_for_day(self, day) -> Path:
return self._log_dir / f"{self._filename_prefix}-{day.isoformat()}{self._suffix}" return (
self._log_dir / f"{self._filename_prefix}-{day.isoformat()}{self._suffix}"
)
def shouldRollover(self, record: logging.LogRecord) -> bool: # noqa: N802 def shouldRollover(self, record: logging.LogRecord) -> bool: # noqa: N802
if datetime.now().date() != self._active_day: if datetime.now().date() != self._active_day:
@@ -133,7 +139,9 @@ class _SizeAndDayRotatingFileHandler(BaseRotatingHandler):
archive = source.with_name(f"{source.name}.{stamp}.{os.getpid()}.gz") archive = source.with_name(f"{source.name}.{stamp}.{os.getpid()}.gz")
sequence = 1 sequence = 1
while archive.exists(): while archive.exists():
archive = source.with_name(f"{source.name}.{stamp}.{os.getpid()}.{sequence}.gz") archive = source.with_name(
f"{source.name}.{stamp}.{os.getpid()}.{sequence}.gz"
)
sequence += 1 sequence += 1
with source.open("rb") as raw, gzip.open(archive, "wb") as compressed: with source.open("rb") as raw, gzip.open(archive, "wb") as compressed:
shutil.copyfileobj(raw, compressed) shutil.copyfileobj(raw, compressed)
@@ -160,7 +168,9 @@ def _configure_standard_logging(level: str, log_path: Path) -> logging.Logger:
"""将业务日志和关键框架日志收敛到单一安全轮转文件。""" """将业务日志和关键框架日志收敛到单一安全轮转文件。"""
log_level = getattr(logging, level.upper(), logging.INFO) log_level = getattr(logging, level.upper(), logging.INFO)
framework_level = max(log_level, logging.INFO) framework_level = max(log_level, logging.INFO)
formatter = logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s") formatter = logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
)
redaction_filter = _SensitiveDataFilter() redaction_filter = _SensitiveDataFilter()
console_handler = logging.StreamHandler(sys.stdout) console_handler = logging.StreamHandler(sys.stdout)
@@ -168,9 +178,13 @@ def _configure_standard_logging(level: str, log_path: Path) -> logging.Logger:
console_handler.setFormatter(formatter) console_handler.setFormatter(formatter)
console_handler.addFilter(redaction_filter) console_handler.addFilter(redaction_filter)
retention_days = _parse_positive_int(os.getenv("LOG_RETENTION_DAYS"), _DEFAULT_RETENTION_DAYS) retention_days = _parse_positive_int(
os.getenv("LOG_RETENTION_DAYS"), _DEFAULT_RETENTION_DAYS
)
rotation_size = _parse_size(os.getenv("LOG_ROTATION_SIZE")) rotation_size = _parse_size(os.getenv("LOG_ROTATION_SIZE"))
file_handler = _SizeAndDayRotatingFileHandler(log_path, rotation_size, retention_days) file_handler = _SizeAndDayRotatingFileHandler(
log_path, rotation_size, retention_days
)
file_handler.setLevel(log_level) file_handler.setLevel(log_level)
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
file_handler.addFilter(redaction_filter) file_handler.addFilter(redaction_filter)
@@ -199,7 +213,9 @@ def _configure_standard_logging(level: str, log_path: Path) -> logging.Logger:
return app_logger return app_logger
def setup_logger(level: str = "INFO", log_dir: str | None = None, log_file: str | None = None) -> None: def setup_logger(
level: str = "INFO", log_dir: str | None = None, log_file: str | None = None
) -> None:
"""配置控制台和文件日志。 """配置控制台和文件日志。
``log_dir`` 默认写入 ``app-YYYY-MM-DD.log````log_file`` 可指定完整文件名环境变量 ``log_dir`` 默认写入 ``app-YYYY-MM-DD.log````log_file`` 可指定完整文件名环境变量
+19 -7
View File
@@ -91,7 +91,7 @@ def encrypt_value(value: str | None) -> str | None:
def _decrypt_with_candidate(value: str, candidate: _KeyCandidate) -> str: def _decrypt_with_candidate(value: str, candidate: _KeyCandidate) -> str:
raw = _b64d(value[len(_PREFIX):]) raw = _b64d(value[len(_PREFIX) :])
if len(raw) < 28: if len(raw) < 28:
raise ValueError("密文字段长度无效") raise ValueError("密文字段长度无效")
nonce = raw[:12] nonce = raw[:12]
@@ -112,7 +112,9 @@ def decrypt_value(value: str | None) -> str | None:
return _decrypt_with_candidate(value, candidate) return _decrypt_with_candidate(value, candidate)
except Exception as exc: except Exception as exc:
last_error = exc last_error = exc
raise ValueError("敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确") from last_error raise ValueError(
"敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确"
) from last_error
def decrypt_value_with_key_name(value: str) -> tuple[str, str]: def decrypt_value_with_key_name(value: str) -> tuple[str, str]:
@@ -125,7 +127,9 @@ def decrypt_value_with_key_name(value: str) -> tuple[str, str]:
return _decrypt_with_candidate(value, candidate), candidate.name return _decrypt_with_candidate(value, candidate), candidate.name
except Exception as exc: except Exception as exc:
last_error = exc last_error = exc
raise ValueError("敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确") from last_error raise ValueError(
"敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确"
) from last_error
class EncryptedText(TypeDecorator): class EncryptedText(TypeDecorator):
@@ -184,9 +188,15 @@ def encrypt_existing_sensitive_data(engine: Engine) -> int:
for table_name, column_name in _SENSITIVE_COLUMNS: for table_name, column_name in _SENSITIVE_COLUMNS:
if not _table_exists(engine, table_name): if not _table_exists(engine, table_name):
continue continue
rows = conn.execute( rows = (
text(f"SELECT id, {column_name} FROM {table_name} WHERE {column_name} IS NOT NULL") conn.execute(
).mappings().all() text(
f"SELECT id, {column_name} FROM {table_name} WHERE {column_name} IS NOT NULL"
)
)
.mappings()
.all()
)
for row in rows: for row in rows:
raw_value = row[column_name] raw_value = row[column_name]
if raw_value is None or raw_value == "": if raw_value is None or raw_value == "":
@@ -204,7 +214,9 @@ def encrypt_existing_sensitive_data(engine: Engine) -> int:
continue continue
encrypted = encrypt_value(plain_value) encrypted = encrypt_value(plain_value)
conn.execute( conn.execute(
text(f"UPDATE {table_name} SET {column_name} = :value WHERE id = :id"), text(
f"UPDATE {table_name} SET {column_name} = :value WHERE id = :id"
),
{"value": encrypted, "id": row["id"]}, {"value": encrypted, "id": row["id"]},
) )
changed += 1 changed += 1
+8 -2
View File
@@ -32,7 +32,9 @@ def _get_database_url() -> str:
db_user = os.getenv("DB_USER", "douyu_login").strip() db_user = os.getenv("DB_USER", "douyu_login").strip()
db_password = os.getenv("DB_PASSWORD", "") db_password = os.getenv("DB_PASSWORD", "")
if not db_name or not db_user or not db_password: if not db_name or not db_user or not db_password:
raise RuntimeError("使用 DB_HOST 时必须同时设置 DB_NAME、DB_USER 和 DB_PASSWORD") raise RuntimeError(
"使用 DB_HOST 时必须同时设置 DB_NAME、DB_USER 和 DB_PASSWORD"
)
return ( return (
f"mysql+pymysql://{quote_plus(db_user)}:{quote_plus(db_password)}" f"mysql+pymysql://{quote_plus(db_user)}:{quote_plus(db_password)}"
f"@{db_host}:{db_port}/{db_name}?charset=utf8mb4" f"@{db_host}:{db_port}/{db_name}?charset=utf8mb4"
@@ -69,6 +71,7 @@ engine = create_engine(DATABASE_URL, **engine_options)
if DATABASE_URL.startswith("sqlite"): if DATABASE_URL.startswith("sqlite"):
@event.listens_for(engine, "connect") @event.listens_for(engine, "connect")
def _set_sqlite_pragmas(dbapi_connection, connection_record): def _set_sqlite_pragmas(dbapi_connection, connection_record):
"""提升 SQLite 并发写入稳定性。""" """提升 SQLite 并发写入稳定性。"""
@@ -78,6 +81,7 @@ if DATABASE_URL.startswith("sqlite"):
cursor.execute("PRAGMA foreign_keys=ON") cursor.execute("PRAGMA foreign_keys=ON")
cursor.close() cursor.close()
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
Base = declarative_base() Base = declarative_base()
@@ -104,7 +108,9 @@ def run_migrations():
from alembic.config import Config from alembic.config import Config
config = Config(str(PROJECT_ROOT / "alembic.ini")) config = Config(str(PROJECT_ROOT / "alembic.ini"))
config.set_main_option("script_location", str(PROJECT_ROOT / "web" / "backend" / "migrations")) config.set_main_option(
"script_location", str(PROJECT_ROOT / "web" / "backend" / "migrations")
)
config.set_main_option("sqlalchemy.url", DATABASE_URL) config.set_main_option("sqlalchemy.url", DATABASE_URL)
# 标记为应用内嵌调用,env.py 据此跳过 fileConfig,避免覆盖 uvicorn 日志配置。 # 标记为应用内嵌调用,env.py 据此跳过 fileConfig,避免覆盖 uvicorn 日志配置。
os.environ["ALEMBIC_EMBEDDED"] = "1" os.environ["ALEMBIC_EMBEDDED"] = "1"
+23 -3
View File
@@ -12,7 +12,20 @@ from fastapi.responses import FileResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from .database import init_db from .database import init_db
from .routers import auth, users, accounts, account_check, dashboard, login, proxy, cookies, huya, douyu, yyb, audit from .routers import (
auth,
users,
accounts,
account_check,
dashboard,
login,
proxy,
cookies,
huya,
douyu,
yyb,
audit,
)
from .schemas import AppInfo from .schemas import AppInfo
from .version import get_app_version from .version import get_app_version
from utils import setup_logger from utils import setup_logger
@@ -22,7 +35,9 @@ from utils import setup_logger
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# 本地 dev.sh 和 Docker 均显式设置 LOG_DIR;直接运行时回退到项目 logs/。 # 本地 dev.sh 和 Docker 均显式设置 LOG_DIR;直接运行时回退到项目 logs/。
_log_level = os.getenv("LOG_LEVEL", "INFO") _log_level = os.getenv("LOG_LEVEL", "INFO")
_log_dir = Path(os.getenv("LOG_DIR", str(Path(__file__).resolve().parents[2] / "logs"))) _log_dir = Path(
os.getenv("LOG_DIR", str(Path(__file__).resolve().parents[2] / "logs"))
)
setup_logger(level=_log_level, log_dir=str(_log_dir)) setup_logger(level=_log_level, log_dir=str(_log_dir))
init_db() init_db()
@@ -46,6 +61,7 @@ async def lifespan(app: FastAPI):
if cleaned_relogin: if cleaned_relogin:
logger.info(f"启动清理 CK 重登残留任务: {cleaned_relogin}") logger.info(f"启动清理 CK 重登残留任务: {cleaned_relogin}")
from .services.yyb_service import cleanup_orphan_yyb_tasks from .services.yyb_service import cleanup_orphan_yyb_tasks
cleaned_yyb = cleanup_orphan_yyb_tasks(db, message="任务已中断(服务重启)") cleaned_yyb = cleanup_orphan_yyb_tasks(db, message="任务已中断(服务重启)")
if cleaned_yyb: if cleaned_yyb:
logger.info(f"启动清理应用宝残留任务: {cleaned_yyb}") logger.info(f"启动清理应用宝残留任务: {cleaned_yyb}")
@@ -65,7 +81,11 @@ _cors_env = os.getenv("CORS_ORIGINS", "")
if _cors_env: if _cors_env:
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()] _cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
else: else:
_cors_origins = ["http://localhost:5174", "http://localhost:5173", "http://localhost:3000"] _cors_origins = [
"http://localhost:5174",
"http://localhost:5173",
"http://localhost:3000",
]
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -39,7 +39,9 @@ def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> bool:
return True return True
def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None: def _create_index_if_missing(
bind, name: str, table_name: str, columns: list[str], unique: bool = False
) -> None:
if name not in _indexes(bind, table_name): if name not in _indexes(bind, table_name):
op.create_index(name, table_name, columns, unique=unique) op.create_index(name, table_name, columns, unique=unique)
@@ -62,8 +64,12 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
) )
else: else:
_add_column_if_missing(bind, "users", sa.Column("custom_permissions", sa.JSON(), nullable=True)) _add_column_if_missing(
_create_index_if_missing(bind, "ix_users_username", "users", ["username"], unique=True) bind, "users", sa.Column("custom_permissions", sa.JSON(), nullable=True)
)
_create_index_if_missing(
bind, "ix_users_username", "users", ["username"], unique=True
)
if not _has_table(bind, "accounts"): if not _has_table(bind, "accounts"):
op.create_table( op.create_table(
@@ -84,8 +90,14 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
) )
else: else:
_add_column_if_missing(bind, "accounts", sa.Column("tag", sa.String(length=64), server_default="")) _add_column_if_missing(
added_ssl = _add_column_if_missing(bind, "accounts", sa.Column("email_imap_ssl", sa.Boolean(), server_default=sa.text("1"))) bind, "accounts", sa.Column("tag", sa.String(length=64), server_default="")
)
added_ssl = _add_column_if_missing(
bind,
"accounts",
sa.Column("email_imap_ssl", sa.Boolean(), server_default=sa.text("1")),
)
if added_ssl: if added_ssl:
op.execute( op.execute(
"UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 " "UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 "
@@ -95,7 +107,9 @@ def upgrade() -> None:
"UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 " "UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 "
"WHERE email_imap_server = 'mail.bdhg.xyz'" "WHERE email_imap_server = 'mail.bdhg.xyz'"
) )
_create_index_if_missing(bind, "ix_accounts_assigned_to", "accounts", ["assigned_to"]) _create_index_if_missing(
bind, "ix_accounts_assigned_to", "accounts", ["assigned_to"]
)
if not _has_table(bind, "proxy_config"): if not _has_table(bind, "proxy_config"):
op.create_table( op.create_table(
@@ -127,7 +141,9 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(["created_by"], ["users.id"]), sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
) )
_create_index_if_missing(bind, "ix_login_tasks_batch_id", "login_tasks", ["batch_id"]) _create_index_if_missing(
bind, "ix_login_tasks_batch_id", "login_tasks", ["batch_id"]
)
if not _has_table(bind, "audit_logs"): if not _has_table(bind, "audit_logs"):
op.create_table( op.create_table(
@@ -19,16 +19,30 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
with op.batch_alter_table("accounts") as batch: with op.batch_alter_table("accounts") as batch:
batch.alter_column("password", existing_type=sa.String(length=256), type_=sa.Text()) batch.alter_column(
batch.alter_column("email", existing_type=sa.String(length=128), type_=sa.Text()) "password", existing_type=sa.String(length=256), type_=sa.Text()
batch.alter_column("email_password", existing_type=sa.String(length=256), type_=sa.Text()) )
batch.alter_column(
"email", existing_type=sa.String(length=128), type_=sa.Text()
)
batch.alter_column(
"email_password", existing_type=sa.String(length=256), type_=sa.Text()
)
with op.batch_alter_table("proxy_config") as batch: with op.batch_alter_table("proxy_config") as batch:
batch.alter_column("api_url", existing_type=sa.String(length=512), type_=sa.Text()) batch.alter_column(
"api_url", existing_type=sa.String(length=512), type_=sa.Text()
)
batch.alter_column("http", existing_type=sa.String(length=256), type_=sa.Text()) batch.alter_column("http", existing_type=sa.String(length=256), type_=sa.Text())
batch.alter_column("https", existing_type=sa.String(length=256), type_=sa.Text()) batch.alter_column(
batch.alter_column("whitelist_uid", existing_type=sa.String(length=64), type_=sa.Text()) "https", existing_type=sa.String(length=256), type_=sa.Text()
batch.alter_column("whitelist_ukey", existing_type=sa.String(length=128), type_=sa.Text()) )
batch.alter_column(
"whitelist_uid", existing_type=sa.String(length=64), type_=sa.Text()
)
batch.alter_column(
"whitelist_ukey", existing_type=sa.String(length=128), type_=sa.Text()
)
with op.batch_alter_table("login_tasks") as batch: with op.batch_alter_table("login_tasks") as batch:
batch.alter_column("cookie", existing_type=sa.Text(), type_=sa.Text()) batch.alter_column("cookie", existing_type=sa.Text(), type_=sa.Text())
@@ -39,13 +53,27 @@ def downgrade() -> None:
batch.alter_column("cookie", existing_type=sa.Text(), type_=sa.Text()) batch.alter_column("cookie", existing_type=sa.Text(), type_=sa.Text())
with op.batch_alter_table("proxy_config") as batch: with op.batch_alter_table("proxy_config") as batch:
batch.alter_column("whitelist_ukey", existing_type=sa.Text(), type_=sa.String(length=128)) batch.alter_column(
batch.alter_column("whitelist_uid", existing_type=sa.Text(), type_=sa.String(length=64)) "whitelist_ukey", existing_type=sa.Text(), type_=sa.String(length=128)
batch.alter_column("https", existing_type=sa.Text(), type_=sa.String(length=256)) )
batch.alter_column(
"whitelist_uid", existing_type=sa.Text(), type_=sa.String(length=64)
)
batch.alter_column(
"https", existing_type=sa.Text(), type_=sa.String(length=256)
)
batch.alter_column("http", existing_type=sa.Text(), type_=sa.String(length=256)) batch.alter_column("http", existing_type=sa.Text(), type_=sa.String(length=256))
batch.alter_column("api_url", existing_type=sa.Text(), type_=sa.String(length=512)) batch.alter_column(
"api_url", existing_type=sa.Text(), type_=sa.String(length=512)
)
with op.batch_alter_table("accounts") as batch: with op.batch_alter_table("accounts") as batch:
batch.alter_column("email_password", existing_type=sa.Text(), type_=sa.String(length=256)) batch.alter_column(
batch.alter_column("email", existing_type=sa.Text(), type_=sa.String(length=128)) "email_password", existing_type=sa.Text(), type_=sa.String(length=256)
batch.alter_column("password", existing_type=sa.Text(), type_=sa.String(length=256)) )
batch.alter_column(
"email", existing_type=sa.Text(), type_=sa.String(length=128)
)
batch.alter_column(
"password", existing_type=sa.Text(), type_=sa.String(length=256)
)
@@ -22,9 +22,7 @@ def upgrade() -> None:
batch.add_column( batch.add_column(
sa.Column("whitelist_platform", sa.String(32), server_default="xiequ") sa.Column("whitelist_platform", sa.String(32), server_default="xiequ")
) )
batch.add_column( batch.add_column(sa.Column("whitelist_credentials", sa.JSON(), nullable=True))
sa.Column("whitelist_credentials", sa.JSON(), nullable=True)
)
def downgrade() -> None: def downgrade() -> None:
@@ -27,7 +27,9 @@ def _indexes(bind, table_name: str) -> set[str]:
return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)} return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None: def _create_index_if_missing(
bind, name: str, table_name: str, columns: list[str], unique: bool = False
) -> None:
if name not in _indexes(bind, table_name): if name not in _indexes(bind, table_name):
op.create_index(name, table_name, columns, unique=unique) op.create_index(name, table_name, columns, unique=unique)
@@ -59,7 +61,9 @@ def upgrade() -> None:
) )
_create_index_if_missing(bind, "ix_huya_accounts_uid", "huya_accounts", ["uid"]) _create_index_if_missing(bind, "ix_huya_accounts_uid", "huya_accounts", ["uid"])
_create_index_if_missing(bind, "ix_huya_accounts_yyuid", "huya_accounts", ["yyuid"]) _create_index_if_missing(bind, "ix_huya_accounts_yyuid", "huya_accounts", ["yyuid"])
_create_index_if_missing(bind, "ix_huya_accounts_assigned_to", "huya_accounts", ["assigned_to"]) _create_index_if_missing(
bind, "ix_huya_accounts_assigned_to", "huya_accounts", ["assigned_to"]
)
if not _has_table(bind, "huya_tasks"): if not _has_table(bind, "huya_tasks"):
op.create_table( op.create_table(
@@ -79,7 +83,9 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
) )
_create_index_if_missing(bind, "ix_huya_tasks_batch_id", "huya_tasks", ["batch_id"]) _create_index_if_missing(bind, "ix_huya_tasks_batch_id", "huya_tasks", ["batch_id"])
_create_index_if_missing(bind, "ix_huya_tasks_task_type", "huya_tasks", ["task_type"]) _create_index_if_missing(
bind, "ix_huya_tasks_task_type", "huya_tasks", ["task_type"]
)
if not _has_table(bind, "huya_config"): if not _has_table(bind, "huya_config"):
op.create_table( op.create_table(
@@ -106,13 +112,17 @@ def upgrade() -> None:
sa.Column("updated_at", sa.DateTime(), nullable=True), sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
) )
_create_index_if_missing(bind, "ix_huya_goods_snapshot_product_id", "huya_goods_snapshot", ["product_id"]) _create_index_if_missing(
bind, "ix_huya_goods_snapshot_product_id", "huya_goods_snapshot", ["product_id"]
)
def downgrade() -> None: def downgrade() -> None:
bind = op.get_bind() bind = op.get_bind()
if _has_table(bind, "huya_goods_snapshot"): if _has_table(bind, "huya_goods_snapshot"):
op.drop_index("ix_huya_goods_snapshot_product_id", table_name="huya_goods_snapshot") op.drop_index(
"ix_huya_goods_snapshot_product_id", table_name="huya_goods_snapshot"
)
op.drop_table("huya_goods_snapshot") op.drop_table("huya_goods_snapshot")
if _has_table(bind, "huya_config"): if _has_table(bind, "huya_config"):
op.drop_table("huya_config") op.drop_table("huya_config")
@@ -27,7 +27,9 @@ def _indexes(bind, table_name: str) -> set[str]:
return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)} return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None: def _create_index_if_missing(
bind, name: str, table_name: str, columns: list[str], unique: bool = False
) -> None:
if name not in _indexes(bind, table_name): if name not in _indexes(bind, table_name):
op.create_index(name, table_name, columns, unique=unique) op.create_index(name, table_name, columns, unique=unique)
@@ -71,7 +73,13 @@ def downgrade() -> None:
if _has_table(bind, "huya_recharge_goods_snapshot"): if _has_table(bind, "huya_recharge_goods_snapshot"):
indexes = _indexes(bind, "huya_recharge_goods_snapshot") indexes = _indexes(bind, "huya_recharge_goods_snapshot")
if "ix_huya_recharge_goods_snapshot_sku_id" in indexes: if "ix_huya_recharge_goods_snapshot_sku_id" in indexes:
op.drop_index("ix_huya_recharge_goods_snapshot_sku_id", table_name="huya_recharge_goods_snapshot") op.drop_index(
"ix_huya_recharge_goods_snapshot_sku_id",
table_name="huya_recharge_goods_snapshot",
)
if "ix_huya_recharge_goods_snapshot_spu_id" in indexes: if "ix_huya_recharge_goods_snapshot_spu_id" in indexes:
op.drop_index("ix_huya_recharge_goods_snapshot_spu_id", table_name="huya_recharge_goods_snapshot") op.drop_index(
"ix_huya_recharge_goods_snapshot_spu_id",
table_name="huya_recharge_goods_snapshot",
)
op.drop_table("huya_recharge_goods_snapshot") op.drop_table("huya_recharge_goods_snapshot")
@@ -59,9 +59,17 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("batch_id"), sa.UniqueConstraint("batch_id"),
) )
op.create_index("ix_huya_register_batches_batch_id", "huya_register_batches", ["batch_id"]) op.create_index(
op.create_index("ix_huya_register_batches_created_by", "huya_register_batches", ["created_by"]) "ix_huya_register_batches_batch_id", "huya_register_batches", ["batch_id"]
op.create_index("ix_huya_register_batches_status", "huya_register_batches", ["status"]) )
op.create_index(
"ix_huya_register_batches_created_by",
"huya_register_batches",
["created_by"],
)
op.create_index(
"ix_huya_register_batches_status", "huya_register_batches", ["status"]
)
if not _has_table(bind, "huya_register_items"): if not _has_table(bind, "huya_register_items"):
op.create_table( op.create_table(
@@ -91,10 +99,18 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(["batch_db_id"], ["huya_register_batches.id"]), sa.ForeignKeyConstraint(["batch_db_id"], ["huya_register_batches.id"]),
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
) )
op.create_index("ix_huya_register_items_batch_db_id", "huya_register_items", ["batch_db_id"]) op.create_index(
op.create_index("ix_huya_register_items_batch_id", "huya_register_items", ["batch_id"]) "ix_huya_register_items_batch_db_id", "huya_register_items", ["batch_db_id"]
op.create_index("ix_huya_register_items_phone", "huya_register_items", ["phone"]) )
op.create_index("ix_huya_register_items_status", "huya_register_items", ["status"]) op.create_index(
"ix_huya_register_items_batch_id", "huya_register_items", ["batch_id"]
)
op.create_index(
"ix_huya_register_items_phone", "huya_register_items", ["phone"]
)
op.create_index(
"ix_huya_register_items_status", "huya_register_items", ["status"]
)
if not _has_table(bind, "huya_register_success_logs"): if not _has_table(bind, "huya_register_success_logs"):
op.create_table( op.create_table(
@@ -116,10 +132,24 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(["created_by"], ["users.id"]), sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
) )
op.create_index("ix_huya_register_success_logs_batch_id", "huya_register_success_logs", ["batch_id"]) op.create_index(
op.create_index("ix_huya_register_success_logs_phone", "huya_register_success_logs", ["phone"]) "ix_huya_register_success_logs_batch_id",
op.create_index("ix_huya_register_success_logs_tag", "huya_register_success_logs", ["tag"]) "huya_register_success_logs",
op.create_index("ix_huya_register_success_logs_created_at", "huya_register_success_logs", ["created_at"]) ["batch_id"],
)
op.create_index(
"ix_huya_register_success_logs_phone",
"huya_register_success_logs",
["phone"],
)
op.create_index(
"ix_huya_register_success_logs_tag", "huya_register_success_logs", ["tag"]
)
op.create_index(
"ix_huya_register_success_logs_created_at",
"huya_register_success_logs",
["created_at"],
)
def downgrade() -> None: def downgrade() -> None:
@@ -38,7 +38,9 @@ def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> None:
op.add_column(table_name, column) op.add_column(table_name, column)
def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None: def _create_index_if_missing(
bind, name: str, table_name: str, columns: list[str], unique: bool = False
) -> None:
if name not in _indexes(bind, table_name): if name not in _indexes(bind, table_name):
op.create_index(name, table_name, columns, unique=unique) op.create_index(name, table_name, columns, unique=unique)
@@ -46,16 +48,40 @@ def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str
def upgrade() -> None: def upgrade() -> None:
bind = op.get_bind() bind = op.get_bind()
_add_column_if_missing(bind, "accounts", sa.Column("uid", sa.String(length=32), nullable=True)) _add_column_if_missing(
_add_column_if_missing(bind, "accounts", sa.Column("nickname", sa.String(length=128), nullable=True)) bind, "accounts", sa.Column("uid", sa.String(length=32), nullable=True)
_add_column_if_missing(bind, "accounts", sa.Column("points", sa.Integer(), nullable=True)) )
_add_column_if_missing(bind, "accounts", sa.Column("game_name", sa.String(length=128), nullable=True)) _add_column_if_missing(
_add_column_if_missing(bind, "accounts", sa.Column("game_channel", sa.String(length=128), nullable=True)) bind, "accounts", sa.Column("nickname", sa.String(length=128), nullable=True)
_add_column_if_missing(bind, "accounts", sa.Column("gold_balance", sa.Integer(), nullable=True)) )
_add_column_if_missing(bind, "accounts", sa.Column("exchange_balance", sa.Integer(), nullable=True)) _add_column_if_missing(
_add_column_if_missing(bind, "accounts", sa.Column("bind_status", sa.String(length=32), nullable=True)) bind, "accounts", sa.Column("points", sa.Integer(), nullable=True)
_add_column_if_missing(bind, "accounts", sa.Column("change_role_wait_time", sa.Integer(), nullable=True)) )
_add_column_if_missing(bind, "accounts", sa.Column("updated_at", sa.DateTime(), nullable=True)) _add_column_if_missing(
bind, "accounts", sa.Column("game_name", sa.String(length=128), nullable=True)
)
_add_column_if_missing(
bind,
"accounts",
sa.Column("game_channel", sa.String(length=128), nullable=True),
)
_add_column_if_missing(
bind, "accounts", sa.Column("gold_balance", sa.Integer(), nullable=True)
)
_add_column_if_missing(
bind, "accounts", sa.Column("exchange_balance", sa.Integer(), nullable=True)
)
_add_column_if_missing(
bind, "accounts", sa.Column("bind_status", sa.String(length=32), nullable=True)
)
_add_column_if_missing(
bind,
"accounts",
sa.Column("change_role_wait_time", sa.Integer(), nullable=True),
)
_add_column_if_missing(
bind, "accounts", sa.Column("updated_at", sa.DateTime(), nullable=True)
)
_create_index_if_missing(bind, "ix_accounts_uid", "accounts", ["uid"]) _create_index_if_missing(bind, "ix_accounts_uid", "accounts", ["uid"])
if not _has_table(bind, "douyu_tasks"): if not _has_table(bind, "douyu_tasks"):
@@ -75,8 +101,12 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(["created_by"], ["users.id"]), sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
) )
_create_index_if_missing(bind, "ix_douyu_tasks_batch_id", "douyu_tasks", ["batch_id"]) _create_index_if_missing(
_create_index_if_missing(bind, "ix_douyu_tasks_task_type", "douyu_tasks", ["task_type"]) bind, "ix_douyu_tasks_batch_id", "douyu_tasks", ["batch_id"]
)
_create_index_if_missing(
bind, "ix_douyu_tasks_task_type", "douyu_tasks", ["task_type"]
)
if not _has_table(bind, "douyu_config"): if not _has_table(bind, "douyu_config"):
op.create_table( op.create_table(
@@ -121,7 +151,10 @@ def downgrade() -> None:
if _has_table(bind, "douyu_goods_snapshot"): if _has_table(bind, "douyu_goods_snapshot"):
indexes = _indexes(bind, "douyu_goods_snapshot") indexes = _indexes(bind, "douyu_goods_snapshot")
if "ix_douyu_goods_snapshot_commodity_id" in indexes: if "ix_douyu_goods_snapshot_commodity_id" in indexes:
op.drop_index("ix_douyu_goods_snapshot_commodity_id", table_name="douyu_goods_snapshot") op.drop_index(
"ix_douyu_goods_snapshot_commodity_id",
table_name="douyu_goods_snapshot",
)
op.drop_table("douyu_goods_snapshot") op.drop_table("douyu_goods_snapshot")
if _has_table(bind, "douyu_config"): if _has_table(bind, "douyu_config"):
op.drop_table("douyu_config") op.drop_table("douyu_config")
@@ -27,7 +27,9 @@ def _indexes(bind, table_name: str) -> set[str]:
return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)} return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str]) -> None: def _create_index_if_missing(
bind, name: str, table_name: str, columns: list[str]
) -> None:
if name not in _indexes(bind, table_name): if name not in _indexes(bind, table_name):
op.create_index(name, table_name, columns) op.create_index(name, table_name, columns)
@@ -50,8 +52,16 @@ INDEXES = [
("ix_huya_tasks_account_id", "huya_tasks", ["account_id", "id"]), ("ix_huya_tasks_account_id", "huya_tasks", ["account_id", "id"]),
("ix_huya_tasks_created_by_id", "huya_tasks", ["created_by", "id"]), ("ix_huya_tasks_created_by_id", "huya_tasks", ["created_by", "id"]),
("ix_huya_tasks_batch_status_id", "huya_tasks", ["batch_id", "status", "id"]), ("ix_huya_tasks_batch_status_id", "huya_tasks", ["batch_id", "status", "id"]),
("ix_huya_register_items_batch_db_line", "huya_register_items", ["batch_db_id", "line"]), (
("ix_huya_register_success_logs_batch_id_id", "huya_register_success_logs", ["batch_id", "id"]), "ix_huya_register_items_batch_db_line",
"huya_register_items",
["batch_db_id", "line"],
),
(
"ix_huya_register_success_logs_batch_id_id",
"huya_register_success_logs",
["batch_id", "id"],
),
] ]
@@ -21,7 +21,10 @@ def upgrade() -> None:
bind = op.get_bind() bind = op.get_bind()
columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")} columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")}
if "esports_can_change_time" not in columns: if "esports_can_change_time" not in columns:
op.add_column("accounts", sa.Column("esports_can_change_time", sa.Integer(), nullable=True)) op.add_column(
"accounts",
sa.Column("esports_can_change_time", sa.Integer(), nullable=True),
)
def downgrade() -> None: def downgrade() -> None:
@@ -23,7 +23,9 @@ def upgrade() -> None:
return return
columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")} columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")}
if "xpd_fragments" not in columns: if "xpd_fragments" not in columns:
op.add_column("accounts", sa.Column("xpd_fragments", sa.Integer(), nullable=True)) op.add_column(
"accounts", sa.Column("xpd_fragments", sa.Integer(), nullable=True)
)
def downgrade() -> None: def downgrade() -> None:
@@ -56,10 +56,8 @@ def upgrade() -> None:
return return
for index in range(0, len(delete_ids), 500): for index in range(0, len(delete_ids), 500):
chunk = delete_ids[index:index + 500] chunk = delete_ids[index : index + 500]
bind.execute( bind.execute(task_table.delete().where(task_table.c.id.in_(chunk)))
task_table.delete().where(task_table.c.id.in_(chunk))
)
def downgrade() -> None: def downgrade() -> None:
@@ -18,7 +18,9 @@ def upgrade() -> None:
"yyb_recharge_tasks", "yyb_recharge_tasks",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("task_id", sa.String(64), nullable=False), sa.Column("task_id", sa.String(64), nullable=False),
sa.Column("created_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), sa.Column(
"created_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=False
),
sa.Column("worker_job_id", sa.String(64), nullable=False), sa.Column("worker_job_id", sa.String(64), nullable=False),
sa.Column("provider", sa.String(16), nullable=False, server_default=""), sa.Column("provider", sa.String(16), nullable=False, server_default=""),
sa.Column("platform", sa.String(16), nullable=False, server_default="android"), sa.Column("platform", sa.String(16), nullable=False, server_default="android"),
@@ -37,9 +39,15 @@ def upgrade() -> None:
sa.Column("created_at", sa.DateTime(), nullable=True), sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("finished_at", sa.DateTime(), nullable=True), sa.Column("finished_at", sa.DateTime(), nullable=True),
) )
op.create_index("uq_yyb_recharge_tasks_task_id", "yyb_recharge_tasks", ["task_id"], unique=True) op.create_index(
op.create_index("ix_yyb_recharge_tasks_created_by", "yyb_recharge_tasks", ["created_by"]) "uq_yyb_recharge_tasks_task_id", "yyb_recharge_tasks", ["task_id"], unique=True
op.create_index("ix_yyb_recharge_tasks_worker_job_id", "yyb_recharge_tasks", ["worker_job_id"]) )
op.create_index(
"ix_yyb_recharge_tasks_created_by", "yyb_recharge_tasks", ["created_by"]
)
op.create_index(
"ix_yyb_recharge_tasks_worker_job_id", "yyb_recharge_tasks", ["worker_job_id"]
)
op.create_index("ix_yyb_recharge_tasks_status", "yyb_recharge_tasks", ["status"]) op.create_index("ix_yyb_recharge_tasks_status", "yyb_recharge_tasks", ["status"])
@@ -18,8 +18,12 @@ def _is_mysql() -> bool:
def upgrade() -> None: def upgrade() -> None:
if _is_mysql(): if _is_mysql():
op.execute("ALTER TABLE yyb_recharge_tasks MODIFY login_qr_data MEDIUMTEXT NULL") op.execute(
op.execute("ALTER TABLE yyb_recharge_tasks MODIFY payment_qr_data MEDIUMTEXT NULL") "ALTER TABLE yyb_recharge_tasks MODIFY login_qr_data MEDIUMTEXT NULL"
)
op.execute(
"ALTER TABLE yyb_recharge_tasks MODIFY payment_qr_data MEDIUMTEXT NULL"
)
def downgrade() -> None: def downgrade() -> None:
@@ -13,10 +13,21 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
op.add_column("yyb_recharge_tasks", sa.Column("price_fen", sa.Integer(), nullable=True)) op.add_column(
op.add_column("yyb_recharge_tasks", sa.Column("payment_started_at", sa.DateTime(), nullable=True)) "yyb_recharge_tasks", sa.Column("price_fen", sa.Integer(), nullable=True)
op.add_column("yyb_recharge_tasks", sa.Column("payment_qr_created_at", sa.DateTime(), nullable=True)) )
op.add_column("yyb_recharge_tasks", sa.Column("payment_last_checked_at", sa.DateTime(), nullable=True)) op.add_column(
"yyb_recharge_tasks",
sa.Column("payment_started_at", sa.DateTime(), nullable=True),
)
op.add_column(
"yyb_recharge_tasks",
sa.Column("payment_qr_created_at", sa.DateTime(), nullable=True),
)
op.add_column(
"yyb_recharge_tasks",
sa.Column("payment_last_checked_at", sa.DateTime(), nullable=True),
)
def downgrade() -> None: def downgrade() -> None:
@@ -23,7 +23,9 @@ def upgrade() -> None:
op.add_column("users", sa.Column("deleted_at", sa.DateTime(), nullable=True)) op.add_column("users", sa.Column("deleted_at", sa.DateTime(), nullable=True))
op.create_index("ix_users_deleted_at", "users", ["deleted_at"]) op.create_index("ix_users_deleted_at", "users", ["deleted_at"])
if "deleted_username" not in columns: if "deleted_username" not in columns:
op.add_column("users", sa.Column("deleted_username", sa.String(length=64), nullable=True)) op.add_column(
"users", sa.Column("deleted_username", sa.String(length=64), nullable=True)
)
def downgrade() -> None: def downgrade() -> None:
@@ -26,35 +26,71 @@ def upgrade() -> None:
if "handbook_scope" not in _columns(bind, "douyu_tasks"): if "handbook_scope" not in _columns(bind, "douyu_tasks"):
op.add_column( op.add_column(
"douyu_tasks", "douyu_tasks",
sa.Column("handbook_scope", sa.String(length=16), nullable=False, server_default="legacy"), sa.Column(
"handbook_scope",
sa.String(length=16),
nullable=False,
server_default="legacy",
),
)
op.create_index(
"ix_douyu_tasks_handbook_scope", "douyu_tasks", ["handbook_scope"]
) )
op.create_index("ix_douyu_tasks_handbook_scope", "douyu_tasks", ["handbook_scope"])
if not sa.inspect(bind).has_table("douyu_workbench_accounts"): if not sa.inspect(bind).has_table("douyu_workbench_accounts"):
op.create_table( op.create_table(
"douyu_workbench_accounts", "douyu_workbench_accounts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), sa.Column(
"user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False
),
sa.Column("handbook_scope", sa.String(length=16), nullable=False), sa.Column("handbook_scope", sa.String(length=16), nullable=False),
sa.Column("account_id", sa.Integer(), sa.ForeignKey("accounts.id"), nullable=False), sa.Column(
"account_id", sa.Integer(), sa.ForeignKey("accounts.id"), nullable=False
),
sa.Column("created_at", sa.DateTime(), nullable=True), sa.Column("created_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("user_id", "handbook_scope", "account_id", name="uq_douyu_workbench_account"), sa.UniqueConstraint(
"user_id",
"handbook_scope",
"account_id",
name="uq_douyu_workbench_account",
),
)
op.create_index(
"ix_douyu_workbench_accounts_user_id",
"douyu_workbench_accounts",
["user_id"],
)
op.create_index(
"ix_douyu_workbench_accounts_handbook_scope",
"douyu_workbench_accounts",
["handbook_scope"],
)
op.create_index(
"ix_douyu_workbench_accounts_account_id",
"douyu_workbench_accounts",
["account_id"],
) )
op.create_index("ix_douyu_workbench_accounts_user_id", "douyu_workbench_accounts", ["user_id"])
op.create_index("ix_douyu_workbench_accounts_handbook_scope", "douyu_workbench_accounts", ["handbook_scope"])
op.create_index("ix_douyu_workbench_accounts_account_id", "douyu_workbench_accounts", ["account_id"])
if not sa.inspect(bind).has_table("douyu_workbenches"): if not sa.inspect(bind).has_table("douyu_workbenches"):
op.create_table( op.create_table(
"douyu_workbenches", "douyu_workbenches",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), sa.Column(
"user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False
),
sa.Column("handbook_scope", sa.String(length=16), nullable=False), sa.Column("handbook_scope", sa.String(length=16), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=True), sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"), sa.UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"),
) )
op.create_index("ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"]) op.create_index(
op.create_index("ix_douyu_workbenches_handbook_scope", "douyu_workbenches", ["handbook_scope"]) "ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"]
)
op.create_index(
"ix_douyu_workbenches_handbook_scope",
"douyu_workbenches",
["handbook_scope"],
)
def downgrade() -> None: def downgrade() -> None:
@@ -30,7 +30,9 @@ def upgrade() -> None:
sa.UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"), sa.UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"),
) )
op.create_index("ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"]) op.create_index("ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"])
op.create_index("ix_douyu_workbenches_handbook_scope", "douyu_workbenches", ["handbook_scope"]) op.create_index(
"ix_douyu_workbenches_handbook_scope", "douyu_workbenches", ["handbook_scope"]
)
def downgrade() -> None: def downgrade() -> None:
@@ -27,9 +27,33 @@ def upgrade() -> None:
return return
columns = _columns(bind) columns = _columns(bind)
additions = ( additions = (
("gold_recharge_channel", sa.Column("gold_recharge_channel", sa.String(length=16), nullable=True, server_default="wechat_qr")), (
("gold_api_product_id", sa.Column("gold_api_product_id", sa.String(length=128), nullable=True, server_default="111570")), "gold_recharge_channel",
("gold_api_account_template_name", sa.Column("gold_api_account_template_name", sa.String(length=64), nullable=True, server_default="斗鱼UID")), sa.Column(
"gold_recharge_channel",
sa.String(length=16),
nullable=True,
server_default="wechat_qr",
),
),
(
"gold_api_product_id",
sa.Column(
"gold_api_product_id",
sa.String(length=128),
nullable=True,
server_default="111570",
),
),
(
"gold_api_account_template_name",
sa.Column(
"gold_api_account_template_name",
sa.String(length=64),
nullable=True,
server_default="斗鱼UID",
),
),
) )
for name, column in additions: for name, column in additions:
if name not in columns: if name not in columns:
@@ -41,6 +65,10 @@ def downgrade() -> None:
if not sa.inspect(bind).has_table("douyu_config"): if not sa.inspect(bind).has_table("douyu_config"):
return return
columns = _columns(bind) columns = _columns(bind)
for name in ("gold_api_account_template_name", "gold_api_product_id", "gold_recharge_channel"): for name in (
"gold_api_account_template_name",
"gold_api_product_id",
"gold_recharge_channel",
):
if name in columns: if name in columns:
op.drop_column("douyu_config", name) op.drop_column("douyu_config", name)
@@ -24,7 +24,10 @@ def upgrade() -> None:
return return
columns = {column["name"] for column in inspector.get_columns("douyu_tasks")} columns = {column["name"] for column in inspector.get_columns("douyu_tasks")}
if "supplier_out_order_id" not in columns: if "supplier_out_order_id" not in columns:
op.add_column("douyu_tasks", sa.Column("supplier_out_order_id", sa.String(length=64), nullable=True)) op.add_column(
"douyu_tasks",
sa.Column("supplier_out_order_id", sa.String(length=64), nullable=True),
)
op.create_index( op.create_index(
"ix_douyu_tasks_supplier_out_order_id", "ix_douyu_tasks_supplier_out_order_id",
"douyu_tasks", "douyu_tasks",
@@ -25,10 +25,17 @@ def upgrade() -> None:
return return
columns = {column["name"] for column in inspector.get_columns("huya_accounts")} columns = {column["name"] for column in inspector.get_columns("huya_accounts")}
if "login_channel" not in columns: if "login_channel" not in columns:
op.add_column("huya_accounts", sa.Column("login_channel", sa.String(16), nullable=False, server_default="")) op.add_column(
"huya_accounts",
sa.Column(
"login_channel", sa.String(16), nullable=False, server_default=""
),
)
indexes = {index["name"] for index in inspector.get_indexes("huya_accounts")} indexes = {index["name"] for index in inspector.get_indexes("huya_accounts")}
if "ix_huya_accounts_login_channel" not in indexes: if "ix_huya_accounts_login_channel" not in indexes:
op.create_index("ix_huya_accounts_login_channel", "huya_accounts", ["login_channel"]) op.create_index(
"ix_huya_accounts_login_channel", "huya_accounts", ["login_channel"]
)
def downgrade() -> None: def downgrade() -> None:
@@ -27,8 +27,16 @@ INDEXES = (
["handbook_scope", "task_type", "id"], ["handbook_scope", "task_type", "id"],
), ),
# Supports terminal-task retention and finished-time ordered Cookie views. # Supports terminal-task retention and finished-time ordered Cookie views.
("ix_douyu_tasks_status_finished_at_id", "douyu_tasks", ["status", "finished_at", "id"]), (
("ix_login_tasks_status_finished_at_id", "login_tasks", ["status", "finished_at", "id"]), "ix_douyu_tasks_status_finished_at_id",
"douyu_tasks",
["status", "finished_at", "id"],
),
(
"ix_login_tasks_status_finished_at_id",
"login_tasks",
["status", "finished_at", "id"],
),
) )
+20 -5
View File
@@ -36,7 +36,10 @@ def _audit_log_out(db: Session, row: AuditLog) -> dict:
accounts = ( accounts = (
db.query(DouyuTask, Account) db.query(DouyuTask, Account)
.join(Account, Account.id == DouyuTask.account_id) .join(Account, Account.id == DouyuTask.account_id)
.filter(DouyuTask.batch_id == batch_id, DouyuTask.task_type == "create_gold_qr") .filter(
DouyuTask.batch_id == batch_id,
DouyuTask.task_type == "create_gold_qr",
)
.order_by(DouyuTask.id.asc()) .order_by(DouyuTask.id.asc())
.limit(100) .limit(100)
.all() .all()
@@ -56,14 +59,19 @@ def _audit_log_out(db: Session, row: AuditLog) -> dict:
batch_id = row.target.removeprefix("douyu_batch:") batch_id = row.target.removeprefix("douyu_batch:")
task = ( task = (
db.query(DouyuTask) db.query(DouyuTask)
.filter(DouyuTask.batch_id == batch_id, DouyuTask.task_type == "create_gold_qr") .filter(
DouyuTask.batch_id == batch_id,
DouyuTask.task_type == "create_gold_qr",
)
.order_by(DouyuTask.id.asc()) .order_by(DouyuTask.id.asc())
.first() .first()
) )
task_result = task.result if task and isinstance(task.result, dict) else {} task_result = task.result if task and isinstance(task.result, dict) else {}
recharge_channel = str(task_result.get("recharge_channel") or "wechat_qr") recharge_channel = str(task_result.get("recharge_channel") or "wechat_qr")
detail["recharge_channel"] = recharge_channel detail["recharge_channel"] = recharge_channel
detail["payment_method"] = "API 直充支付" if recharge_channel == "supplier_api" else "微信扫码支付" detail["payment_method"] = (
"API 直充支付" if recharge_channel == "supplier_api" else "微信扫码支付"
)
if isinstance(detail, dict): if isinstance(detail, dict):
detail_text = json.dumps(detail, ensure_ascii=False, separators=(",", ":")) detail_text = json.dumps(detail, ensure_ascii=False, separators=(",", ":"))
return { return {
@@ -99,7 +107,9 @@ def list_audit_logs(
query = query.filter(AuditLog.action == action.strip()) query = query.filter(AuditLog.action == action.strip())
if keyword: if keyword:
pattern = f"%{keyword.strip()}%" pattern = f"%{keyword.strip()}%"
query = query.filter((AuditLog.target.ilike(pattern)) | (AuditLog.detail.ilike(pattern))) query = query.filter(
(AuditLog.target.ilike(pattern)) | (AuditLog.detail.ilike(pattern))
)
if success is not None: if success is not None:
query = query.filter(AuditLog.success == success) query = query.filter(AuditLog.success == success)
if start_time: if start_time:
@@ -107,7 +117,12 @@ def list_audit_logs(
if end_time: if end_time:
query = query.filter(AuditLog.created_at <= end_time) query = query.filter(AuditLog.created_at <= end_time)
total = query.count() total = query.count()
rows = query.order_by(AuditLog.id.desc()).offset((page - 1) * page_size).limit(page_size).all() rows = (
query.order_by(AuditLog.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return { return {
"items": [_audit_log_out(db, row) for row in rows], "items": [_audit_log_out(db, row) for row in rows],
"total": total, "total": total,
+16 -3
View File
@@ -41,7 +41,9 @@ def login(req: LoginRequest, response: Response, db: Session = Depends(get_db)):
) )
# 审计 # 审计
db.add(AuditLog(user_id=user.id, username=user.username, action="login", target="auth")) db.add(
AuditLog(user_id=user.id, username=user.username, action="login", target="auth")
)
db.commit() db.commit()
return TokenResponse( return TokenResponse(
@@ -66,8 +68,19 @@ def me(current_user: User = Depends(get_current_user)):
@router.post("/logout") @router.post("/logout")
def logout(response: Response, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): def logout(
response: Response,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
response.delete_cookie(key="access_token", path="/") response.delete_cookie(key="access_token", path="/")
db.add(AuditLog(user_id=current_user.id, username=current_user.username, action="logout", target="auth")) db.add(
AuditLog(
user_id=current_user.id,
username=current_user.username,
action="logout",
target="auth",
)
)
db.commit() db.commit()
return {"message": "已登出"} return {"message": "已登出"}
+37 -17
View File
@@ -44,7 +44,9 @@ def _empty_task_summary() -> dict:
def _can_view_huya_all(user: User) -> bool: def _can_view_huya_all(user: User) -> bool:
"""兼容旧 huya:account 全量权限。""" """兼容旧 huya:account 全量权限。"""
return user_has_permission(user, "huya:view_all") or user_has_permission(user, "huya:account") return user_has_permission(user, "huya:view_all") or user_has_permission(
user, "huya:account"
)
@router.get("/summary") @router.get("/summary")
@@ -65,12 +67,17 @@ def dashboard_summary(
) )
login_tasks = _empty_task_summary() login_tasks = _empty_task_summary()
if any(user_has_permission(current, permission) for permission in ( if any(
"login:batch", user_has_permission(current, permission)
"login:view_all", for permission in (
"login:view_assigned", "login:batch",
)): "login:view_all",
login_query = db.query(LoginTask).join(Account, LoginTask.account_id == Account.id) "login:view_assigned",
)
):
login_query = db.query(LoginTask).join(
Account, LoginTask.account_id == Account.id
)
if not user_has_permission(current, "login:view_all"): if not user_has_permission(current, "login:view_all"):
login_query = login_query.filter(Account.assigned_to == current.id) login_query = login_query.filter(Account.assigned_to == current.id)
login_tasks = _task_summary(login_query, LoginTask) login_tasks = _task_summary(login_query, LoginTask)
@@ -87,27 +94,40 @@ def dashboard_summary(
cookies = cookie_query.with_entities(func.count(LoginTask.id)).scalar() or 0 cookies = cookie_query.with_entities(func.count(LoginTask.id)).scalar() or 0
huya_accounts = 0 huya_accounts = 0
can_view_huya_accounts = any(user_has_permission(current, permission) for permission in ( can_view_huya_accounts = any(
"huya:account", user_has_permission(current, permission)
"huya:view_all", for permission in (
"huya:view_assigned", "huya:account",
)) "huya:view_all",
"huya:view_assigned",
)
)
if can_view_huya_accounts: if can_view_huya_accounts:
huya_account_query = db.query(HuyaAccount) huya_account_query = db.query(HuyaAccount)
if not _can_view_huya_all(current): if not _can_view_huya_all(current):
huya_account_query = huya_account_query.filter(HuyaAccount.assigned_to == current.id) huya_account_query = huya_account_query.filter(
huya_accounts = huya_account_query.with_entities(func.count(HuyaAccount.id)).scalar() or 0 HuyaAccount.assigned_to == current.id
)
huya_accounts = (
huya_account_query.with_entities(func.count(HuyaAccount.id)).scalar() or 0
)
huya_tasks = _empty_task_summary() huya_tasks = _empty_task_summary()
huya_goods = 0 huya_goods = 0
huya_recharge_goods = 0 huya_recharge_goods = 0
if user_has_permission(current, "huya:task"): if user_has_permission(current, "huya:task"):
huya_task_query = db.query(HuyaTask).join(HuyaAccount, HuyaTask.account_id == HuyaAccount.id) huya_task_query = db.query(HuyaTask).join(
HuyaAccount, HuyaTask.account_id == HuyaAccount.id
)
if not _can_view_huya_all(current): if not _can_view_huya_all(current):
huya_task_query = huya_task_query.filter(HuyaAccount.assigned_to == current.id) huya_task_query = huya_task_query.filter(
HuyaAccount.assigned_to == current.id
)
huya_tasks = _task_summary(huya_task_query, HuyaTask) huya_tasks = _task_summary(huya_task_query, HuyaTask)
huya_goods = db.query(func.count(HuyaGoodsSnapshot.id)).scalar() or 0 huya_goods = db.query(func.count(HuyaGoodsSnapshot.id)).scalar() or 0
huya_recharge_goods = db.query(func.count(HuyaRechargeGoodsSnapshot.id)).scalar() or 0 huya_recharge_goods = (
db.query(func.count(HuyaRechargeGoodsSnapshot.id)).scalar() or 0
)
return { return {
"douyu": { "douyu": {
+24 -8
View File
@@ -106,7 +106,11 @@ def list_tasks(
current: User = Depends(get_current_user), current: User = Depends(get_current_user),
): ):
"""查看登录任务列表。""" """查看登录任务列表。"""
query = db.query(LoginTask).options(defer(LoginTask.cookie)).join(Account, LoginTask.account_id == Account.id) query = (
db.query(LoginTask)
.options(defer(LoginTask.cookie))
.join(Account, LoginTask.account_id == Account.id)
)
# 客服只能看自己账号的任务 # 客服只能看自己账号的任务
if not user_has_permission(current, "login:view_all"): if not user_has_permission(current, "login:view_all"):
@@ -130,12 +134,20 @@ def list_tasks(
result = [] result = []
for t in rows: for t in rows:
result.append(LoginTaskOut( result.append(
id=t.id, batch_id=t.batch_id, account_id=t.account_id, LoginTaskOut(
account_username=accounts_map.get(t.account_id, ""), id=t.id,
status=t.status, cookie="", message=t.message or "", batch_id=t.batch_id,
created_by=t.created_by, created_at=t.created_at, finished_at=t.finished_at, account_id=t.account_id,
)) account_username=accounts_map.get(t.account_id, ""),
status=t.status,
cookie="",
message=t.message or "",
created_by=t.created_by,
created_at=t.created_at,
finished_at=t.finished_at,
)
)
return result return result
@@ -178,7 +190,11 @@ def delete_tasks(
ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()] ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()]
if not ids: if not ids:
raise HTTPException(status_code=400, detail="无效的任务ID") raise HTTPException(status_code=400, detail="无效的任务ID")
deleted = db.query(LoginTask).filter(LoginTask.id.in_(ids)).delete(synchronize_session=False) deleted = (
db.query(LoginTask)
.filter(LoginTask.id.in_(ids))
.delete(synchronize_session=False)
)
db.commit() db.commit()
return {"message": f"已删除 {deleted} 个任务", "deleted": deleted, "success": True} return {"message": f"已删除 {deleted} 个任务", "deleted": deleted, "success": True}
+15 -7
View File
@@ -11,7 +11,9 @@ from ..schemas import ProxyConfigOut, ProxyConfigUpdate, PlatformInfo, PlatformF
from ..deps import require_permission, authenticate_websocket from ..deps import require_permission, authenticate_websocket
from ..services.proxy_service import proxy_service from ..services.proxy_service import proxy_service
from core.douyu.proxy_platforms import ( from core.douyu.proxy_platforms import (
get_platform_names, get_platform_labels, get_credential_fields, get_platform_names,
get_platform_labels,
get_credential_fields,
) )
router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"]) router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"])
@@ -37,7 +39,9 @@ def update_proxy_config(
api_url=req.api_url if req.api_url is not None else "", api_url=req.api_url if req.api_url is not None else "",
http=req.http if req.http is not None else "", http=req.http if req.http is not None else "",
https=req.https if req.https is not None else "", https=req.https if req.https is not None else "",
whitelist_enabled=req.whitelist_enabled if req.whitelist_enabled is not None else False, whitelist_enabled=req.whitelist_enabled
if req.whitelist_enabled is not None
else False,
whitelist_platform=req.whitelist_platform or "xiequ", whitelist_platform=req.whitelist_platform or "xiequ",
whitelist_credentials=req.whitelist_credentials, whitelist_credentials=req.whitelist_credentials,
whitelist_uid=req.whitelist_uid, whitelist_uid=req.whitelist_uid,
@@ -53,16 +57,19 @@ def list_platforms():
result = [] result = []
for name in get_platform_names(): for name in get_platform_names():
fields = get_credential_fields(name) fields = get_credential_fields(name)
result.append(PlatformInfo( result.append(
name=name, PlatformInfo(
label=labels.get(name, name), name=name,
credential_fields=[PlatformFieldDef(**f) for f in fields], label=labels.get(name, name),
)) credential_fields=[PlatformFieldDef(**f) for f in fields],
)
)
return result return result
# ---- WebSocket 日志推送 ---- # ---- WebSocket 日志推送 ----
@router.websocket("/ws/test/{test_id}") @router.websocket("/ws/test/{test_id}")
async def ws_test_logs(websocket: WebSocket, test_id: str): async def ws_test_logs(websocket: WebSocket, test_id: str):
"""WebSocket 推送代理/白名单测试实时日志(需认证)。""" """WebSocket 推送代理/白名单测试实时日志(需认证)。"""
@@ -100,6 +107,7 @@ async def ws_test_logs(websocket: WebSocket, test_id: str):
# ---- 异步测试 API 端点 ---- # ---- 异步测试 API 端点 ----
@router.post("/test") @router.post("/test")
async def test_proxy( async def test_proxy(
db: Session = Depends(get_db), db: Session = Depends(get_db),
+69 -32
View File
@@ -25,16 +25,18 @@ def list_users(
users = db.query(User).filter(User.deleted_at.is_(None)).order_by(User.id).all() users = db.query(User).filter(User.deleted_at.is_(None)).order_by(User.id).all()
result = [] result = []
for u in users: for u in users:
result.append(UserInfo( result.append(
id=u.id, UserInfo(
username=u.username, id=u.id,
role=u.role, username=u.username,
is_active=u.is_active, role=u.role,
remark=u.remark or "", is_active=u.is_active,
created_at=u.created_at, remark=u.remark or "",
permissions=get_user_permissions(u), created_at=u.created_at,
custom_permissions=u.custom_permissions, permissions=get_user_permissions(u),
)) custom_permissions=u.custom_permissions,
)
)
return result return result
@@ -58,13 +60,22 @@ def create_user(
db.commit() db.commit()
db.refresh(user) db.refresh(user)
db.add(AuditLog(user_id=current.id, username=current.username, db.add(
action="user:create", target=user.username)) AuditLog(
user_id=current.id,
username=current.username,
action="user:create",
target=user.username,
)
)
db.commit() db.commit()
return UserInfo( return UserInfo(
id=user.id, username=user.username, role=user.role, id=user.id,
is_active=user.is_active, remark=user.remark or "", username=user.username,
role=user.role,
is_active=user.is_active,
remark=user.remark or "",
permissions=get_user_permissions(user), permissions=get_user_permissions(user),
custom_permissions=user.custom_permissions, custom_permissions=user.custom_permissions,
) )
@@ -97,13 +108,22 @@ def update_user(
db.commit() db.commit()
db.refresh(user) db.refresh(user)
db.add(AuditLog(user_id=current.id, username=current.username, db.add(
action="user:edit", target=user.username)) AuditLog(
user_id=current.id,
username=current.username,
action="user:edit",
target=user.username,
)
)
db.commit() db.commit()
return UserInfo( return UserInfo(
id=user.id, username=user.username, role=user.role, id=user.id,
is_active=user.is_active, remark=user.remark or "", username=user.username,
role=user.role,
is_active=user.is_active,
remark=user.remark or "",
permissions=get_user_permissions(user), permissions=get_user_permissions(user),
custom_permissions=user.custom_permissions, custom_permissions=user.custom_permissions,
) )
@@ -126,21 +146,27 @@ def rename_user(
raise HTTPException(status_code=400, detail="用户名至少 2 个字符") raise HTTPException(status_code=400, detail="用户名至少 2 个字符")
if username == user.username: if username == user.username:
return UserInfo( return UserInfo(
id=user.id, username=user.username, role=user.role, id=user.id,
is_active=user.is_active, remark=user.remark or "", username=user.username,
permissions=get_user_permissions(user), custom_permissions=user.custom_permissions, role=user.role,
is_active=user.is_active,
remark=user.remark or "",
permissions=get_user_permissions(user),
custom_permissions=user.custom_permissions,
) )
if db.query(User.id).filter(User.username == username).first(): if db.query(User.id).filter(User.username == username).first():
raise HTTPException(status_code=400, detail="用户名已存在") raise HTTPException(status_code=400, detail="用户名已存在")
old_username = user.username old_username = user.username
user.username = username user.username = username
db.add(AuditLog( db.add(
user_id=current.id, AuditLog(
username=current.username, user_id=current.id,
action="user:rename", username=current.username,
target=f"{old_username} -> {username}", action="user:rename",
)) target=f"{old_username} -> {username}",
)
)
try: try:
db.commit() db.commit()
except IntegrityError as exc: except IntegrityError as exc:
@@ -148,9 +174,13 @@ def rename_user(
raise HTTPException(status_code=400, detail="用户名已存在") from exc raise HTTPException(status_code=400, detail="用户名已存在") from exc
db.refresh(user) db.refresh(user)
return UserInfo( return UserInfo(
id=user.id, username=user.username, role=user.role, id=user.id,
is_active=user.is_active, remark=user.remark or "", username=user.username,
permissions=get_user_permissions(user), custom_permissions=user.custom_permissions, role=user.role,
is_active=user.is_active,
remark=user.remark or "",
permissions=get_user_permissions(user),
custom_permissions=user.custom_permissions,
) )
@@ -182,8 +212,14 @@ def delete_user(
.update({HuyaAccount.assigned_to: None}, synchronize_session=False) .update({HuyaAccount.assigned_to: None}, synchronize_session=False)
) )
deleted_username = user.username deleted_username = user.username
db.add(AuditLog(user_id=current.id, username=current.username, db.add(
action="user:delete", target=deleted_username)) AuditLog(
user_id=current.id,
username=current.username,
action="user:delete",
target=deleted_username,
)
)
user.is_active = False user.is_active = False
user.deleted_at = datetime.now(timezone.utc) user.deleted_at = datetime.now(timezone.utc)
user.deleted_username = deleted_username user.deleted_username = deleted_username
@@ -202,6 +238,7 @@ def delete_user(
def list_permissions(_: User = Depends(require_permission("user:assign_permissions"))): def list_permissions(_: User = Depends(require_permission("user:assign_permissions"))):
"""返回所有可用权限点及角色默认权限映射。""" """返回所有可用权限点及角色默认权限映射。"""
from ..permissions import ROLE_PERMISSIONS from ..permissions import ROLE_PERMISSIONS
return { return {
"permissions": PERMISSIONS, "permissions": PERMISSIONS,
"role_permissions": ROLE_PERMISSIONS, "role_permissions": ROLE_PERMISSIONS,
+172 -45
View File
@@ -20,7 +20,9 @@ from ..services.audit_service import record_audit
router = APIRouter(prefix="/api/yyb", tags=["应用宝充值"]) router = APIRouter(prefix="/api/yyb", tags=["应用宝充值"])
def _get_task(db: Session, task_id: int, current: User, write: bool = False) -> YybRechargeTask: def _get_task(
db: Session, task_id: int, current: User, write: bool = False
) -> YybRechargeTask:
"""读取任务。 """读取任务。
查看:本人或 yyb:history写操作:本人或 yyb:manage 查看:本人或 yyb:history写操作:本人或 yyb:manage
@@ -53,14 +55,26 @@ def _creator_username(db: Session, task: YybRechargeTask) -> str:
@router.post("/tasks") @router.post("/tasks")
def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): def create_task(
payload: YybTaskCreateRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("yyb:session")),
):
data = _worker_call(YybWorkerClient().create_job) data = _worker_call(YybWorkerClient().create_job)
task = YybRechargeTask(task_id=uuid.uuid4().hex[:16], created_by=current.id, task = YybRechargeTask(
worker_job_id=str(data["job_id"]), status=str(data.get("status", "created")), task_id=uuid.uuid4().hex[:16],
phase="login", message="请选择登录方式") created_by=current.id,
worker_job_id=str(data["job_id"]),
status=str(data.get("status", "created")),
phase="login",
message="请选择登录方式",
)
db.add(task) db.add(task)
record_audit( record_audit(
db, current, action="recharge:yyb:create", target=f"yyb_task:{task.task_id}", db,
current,
action="recharge:yyb:create",
target=f"yyb_task:{task.task_id}",
detail={"task_id": task.task_id, "status": task.status}, detail={"task_id": task.task_id, "status": task.status},
) )
db.commit() db.commit()
@@ -69,9 +83,18 @@ def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), cu
@router.post("/tasks/{task_id}/login") @router.post("/tasks/{task_id}/login")
def login(task_id: int, payload: YybLoginRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): def login(
task_id: int,
payload: YybLoginRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("yyb:session")),
):
task = _get_task(db, task_id, current, write=True) task = _get_task(db, task_id, current, write=True)
data = _worker_call(lambda: YybWorkerClient().login(task.worker_job_id, payload.provider, payload.timeout)) data = _worker_call(
lambda: YybWorkerClient().login(
task.worker_job_id, payload.provider, payload.timeout
)
)
task.provider = payload.provider task.provider = payload.provider
task.status = str(data.get("status", "waiting_login")) task.status = str(data.get("status", "waiting_login"))
task.phase = "login" task.phase = "login"
@@ -79,31 +102,48 @@ def login(task_id: int, payload: YybLoginRequest, db: Session = Depends(get_db),
if data.get("qr_data"): if data.get("qr_data"):
task.login_qr_data = data["qr_data"] task.login_qr_data = data["qr_data"]
record_audit( record_audit(
db, current, action="recharge:yyb:login", target=f"yyb_task:{task.task_id}", db,
detail={"task_id": task.task_id, "provider": payload.provider, "status": task.status}, current,
action="recharge:yyb:login",
target=f"yyb_task:{task.task_id}",
detail={
"task_id": task.task_id,
"provider": payload.provider,
"status": task.status,
},
) )
db.commit() db.commit()
return public_task(task, creator_username=_creator_username(db, task)) return public_task(task, creator_username=_creator_username(db, task))
@router.get("/tasks/{task_id}") @router.get("/tasks/{task_id}")
def get_task(task_id: int, db: Session = Depends(get_db), current: User = Depends(get_current_user)): def get_task(
task_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
task = _get_task(db, task_id, current) task = _get_task(db, task_id, current)
try: try:
sync_task(db, task, YybWorkerClient()) sync_task(db, task, YybWorkerClient())
except YybWorkerError: except YybWorkerError:
# Worker 暂时重启时仍返回最近一次持久化状态。 # Worker 暂时重启时仍返回最近一次持久化状态。
pass pass
return public_task(task, include_qr=user_has_permission(current, "yyb:session"), return public_task(
include_payment_qr=user_has_permission(current, "yyb:recharge"), task,
creator_username=_creator_username(db, task)) include_qr=user_has_permission(current, "yyb:session"),
include_payment_qr=user_has_permission(current, "yyb:recharge"),
creator_username=_creator_username(db, task),
)
@router.get("/tasks") @router.get("/tasks")
def list_tasks(scope: str = Query("mine", pattern="^(mine|all)$"), def list_tasks(
status: str | None = Query(None, max_length=32), scope: str = Query("mine", pattern="^(mine|all)$"),
limit: int = Query(50, ge=1, le=200), status: str | None = Query(None, max_length=32),
db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current: User = Depends(require_permission("yyb:session")),
):
if scope == "all" and not user_has_permission(current, "yyb:history"): if scope == "all" and not user_has_permission(current, "yyb:history"):
raise HTTPException(403, "无权查看全部充值任务") raise HTTPException(403, "无权查看全部充值任务")
query = db.query(YybRechargeTask) query = db.query(YybRechargeTask)
@@ -114,36 +154,80 @@ def list_tasks(scope: str = Query("mine", pattern="^(mine|all)$"),
rows = query.order_by(YybRechargeTask.id.desc()).limit(limit).all() rows = query.order_by(YybRechargeTask.id.desc()).limit(limit).all()
usernames = { usernames = {
user.id: user.username user.id: user.username
for user in db.query(User).filter(User.id.in_({row.created_by for row in rows})).all() for user in db.query(User)
.filter(User.id.in_({row.created_by for row in rows}))
.all()
} }
return [public_task(task, include_qr=False, creator_username=usernames.get(task.created_by, "")) return [
for task in rows] public_task(
task, include_qr=False, creator_username=usernames.get(task.created_by, "")
)
for task in rows
]
@router.get("/tasks/{task_id}/selection-options") @router.get("/tasks/{task_id}/selection-options")
def selection_options(task_id: int, platform: str = Query("android"), points: int | None = Query(None), zone_id: str | None = Query(None), db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): def selection_options(
task_id: int,
platform: str = Query("android"),
points: int | None = Query(None),
zone_id: str | None = Query(None),
db: Session = Depends(get_db),
current: User = Depends(require_permission("yyb:session")),
):
task = _get_task(db, task_id, current, write=True) task = _get_task(db, task_id, current, write=True)
data = _worker_call(lambda: YybWorkerClient().selection_options(task.worker_job_id, platform, points, zone_id)) data = _worker_call(
lambda: YybWorkerClient().selection_options(
task.worker_job_id, platform, points, zone_id
)
)
task.platform = platform task.platform = platform
db.commit() db.commit()
return data return data
@router.post("/tasks/{task_id}/selection") @router.post("/tasks/{task_id}/selection")
def selection(task_id: int, payload: YybSelectionRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): def selection(
task_id: int,
payload: YybSelectionRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("yyb:session")),
):
task = _get_task(db, task_id, current, write=True) task = _get_task(db, task_id, current, write=True)
data = _worker_call(lambda: YybWorkerClient().selection(task.worker_job_id, payload.model_dump())) data = _worker_call(
lambda: YybWorkerClient().selection(task.worker_job_id, payload.model_dump())
)
selected = data.get("selection", payload.model_dump()) selected = data.get("selection", payload.model_dump())
for field in ("platform", "points", "product_id", "zone_id", "zone_name", "role_id", "role_name"): for field in (
"platform",
"points",
"product_id",
"zone_id",
"zone_name",
"role_id",
"role_name",
):
setattr(task, field, selected[field]) setattr(task, field, selected[field])
task.price_fen = int(selected.get("price_fen") or 0) task.price_fen = int(selected.get("price_fen") or 0)
task.phase, task.status, task.message = "payment", "ready", "选择已保存,可以生成付款码" task.phase, task.status, task.message = (
"payment",
"ready",
"选择已保存,可以生成付款码",
)
record_audit( record_audit(
db, current, action="recharge:yyb:selection", target=f"yyb_task:{task.task_id}", db,
current,
action="recharge:yyb:selection",
target=f"yyb_task:{task.task_id}",
detail={ detail={
"task_id": task.task_id, "product_id": task.product_id, "points": task.points, "task_id": task.task_id,
"zone_id": task.zone_id, "zone_name": task.zone_name, "role_id": task.role_id, "product_id": task.product_id,
"role_name": task.role_name, "price_fen": task.price_fen, "points": task.points,
"zone_id": task.zone_id,
"zone_name": task.zone_name,
"role_id": task.role_id,
"role_name": task.role_name,
"price_fen": task.price_fen,
}, },
) )
db.commit() db.commit()
@@ -151,10 +235,19 @@ def selection(task_id: int, payload: YybSelectionRequest, db: Session = Depends(
@router.post("/tasks/{task_id}/payment") @router.post("/tasks/{task_id}/payment")
def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:recharge"))): def payment(
task_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("yyb:recharge")),
):
# 接手他人任务需 yyb:manage;锁行做原子状态迁移,防止并发双击重复下单。 # 接手他人任务需 yyb:manage;锁行做原子状态迁移,防止并发双击重复下单。
_get_task(db, task_id, current, write=True) _get_task(db, task_id, current, write=True)
task = db.query(YybRechargeTask).filter(YybRechargeTask.id == task_id).with_for_update().first() task = (
db.query(YybRechargeTask)
.filter(YybRechargeTask.id == task_id)
.with_for_update()
.first()
)
if not task: if not task:
raise HTTPException(404, "充值任务不存在") raise HTTPException(404, "充值任务不存在")
if task.status != "ready" or task.phase != "payment": if task.status != "ready" or task.phase != "payment":
@@ -174,8 +267,12 @@ def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends
task.message = "生成付款码失败,请稍后重试" task.message = "生成付款码失败,请稍后重试"
task.payment_started_at = None task.payment_started_at = None
record_audit( record_audit(
db, current, action="recharge:yyb:payment", target=f"yyb_task:{task.task_id}", db,
detail="生成付款码失败", success=False, current,
action="recharge:yyb:payment",
target=f"yyb_task:{task.task_id}",
detail="生成付款码失败",
success=False,
) )
db.commit() db.commit()
raise raise
@@ -183,16 +280,30 @@ def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends
task.phase = str(data.get("phase", "payment")) task.phase = str(data.get("phase", "payment"))
task.message = str(data.get("message", task.message)) task.message = str(data.get("message", task.message))
record_audit( record_audit(
db, current, action="recharge:yyb:payment", target=f"yyb_task:{task.task_id}", db,
detail={"task_id": task.task_id, "product_id": task.product_id, "points": task.points, current,
"price_fen": task.price_fen, "status": task.status}, action="recharge:yyb:payment",
target=f"yyb_task:{task.task_id}",
detail={
"task_id": task.task_id,
"product_id": task.product_id,
"points": task.points,
"price_fen": task.price_fen,
"status": task.status,
},
) )
db.commit() db.commit()
return public_task(task, include_qr=False, creator_username=_creator_username(db, task)) return public_task(
task, include_qr=False, creator_username=_creator_username(db, task)
)
@router.post("/tasks/{task_id}/payment/check") @router.post("/tasks/{task_id}/payment/check")
def payment_check(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:recharge"))): def payment_check(
task_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("yyb:recharge")),
):
task = _get_task(db, task_id, current, write=True) task = _get_task(db, task_id, current, write=True)
if task.status not in {"waiting_payment", "payment_timeout"}: if task.status not in {"waiting_payment", "payment_timeout"}:
raise HTTPException(409, "当前任务状态不支持检测到账") raise HTTPException(409, "当前任务状态不支持检测到账")
@@ -204,16 +315,29 @@ def payment_check(task_id: int, db: Session = Depends(get_db), current: User = D
if task.status == "success": if task.status == "success":
task.phase = "completed" task.phase = "completed"
record_audit( record_audit(
db, current, action="recharge:yyb:payment_check", target=f"yyb_task:{task.task_id}", db,
detail={"task_id": task.task_id, "status": task.status, "message": task.message}, current,
action="recharge:yyb:payment_check",
target=f"yyb_task:{task.task_id}",
detail={
"task_id": task.task_id,
"status": task.status,
"message": task.message,
},
success=task.status != "failed", success=task.status != "failed",
) )
db.commit() db.commit()
return public_task(task, include_qr=False, creator_username=_creator_username(db, task)) return public_task(
task, include_qr=False, creator_username=_creator_username(db, task)
)
@router.post("/tasks/{task_id}/stop") @router.post("/tasks/{task_id}/stop")
def stop(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): def stop(
task_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("yyb:session")),
):
task = _get_task(db, task_id, current, write=True) task = _get_task(db, task_id, current, write=True)
data = _worker_call(lambda: YybWorkerClient().stop(task.worker_job_id)) data = _worker_call(lambda: YybWorkerClient().stop(task.worker_job_id))
task.status = "stopped" task.status = "stopped"
@@ -221,7 +345,10 @@ def stop(task_id: int, db: Session = Depends(get_db), current: User = Depends(re
task.message = str(data.get("message", "任务已停止")) task.message = str(data.get("message", "任务已停止"))
task.finished_at = _utcnow() task.finished_at = _utcnow()
record_audit( record_audit(
db, current, action="recharge:yyb:stop", target=f"yyb_task:{task.task_id}", db,
current,
action="recharge:yyb:stop",
target=f"yyb_task:{task.task_id}",
detail={"task_id": task.task_id, "status": task.status}, detail={"task_id": task.task_id, "status": task.status},
) )
db.commit() db.commit()
+29 -23
View File
@@ -8,27 +8,31 @@ from sqlalchemy.orm import Session
from ..models import Account, LoginTask from ..models import Account, LoginTask
EMAIL_PATTERN = re.compile(r'^[^\s@|]+@[^\s@|]+\.[^\s@|]+$') EMAIL_PATTERN = re.compile(r"^[^\s@|]+@[^\s@|]+\.[^\s@|]+$")
def split_account_line(line: str) -> list[str]: def split_account_line(line: str) -> list[str]:
"""拆分一行账号文本,支持 |、tab、逗号、空格分隔。""" """拆分一行账号文本,支持 |、tab、逗号、空格分隔。"""
if '|' in line: if "|" in line:
return line.split('|') return line.split("|")
if '\t' in line: if "\t" in line:
return line.split('\t') return line.split("\t")
if ',' in line: if "," in line:
return next(csv.reader([line])) return next(csv.reader([line]))
return line.split() return line.split()
def cookie_account_ids_query(db: Session): def cookie_account_ids_query(db: Session):
"""返回有成功登录记录(cookie非空)的账号ID子查询。""" """返回有成功登录记录(cookie非空)的账号ID子查询。"""
return db.query(LoginTask.account_id).filter( return (
LoginTask.status == 'success', db.query(LoginTask.account_id)
LoginTask.cookie != '', .filter(
LoginTask.cookie.isnot(None), LoginTask.status == "success",
).distinct() LoginTask.cookie != "",
LoginTask.cookie.isnot(None),
)
.distinct()
)
def parse_and_build_accounts( def parse_and_build_accounts(
@@ -61,9 +65,9 @@ def parse_and_build_accounts(
skipped = 0 skipped = 0
duplicated = 0 duplicated = 0
seen_in_batch: set[str] = set() seen_in_batch: set[str] = set()
for line in text.strip().split('\n'): for line in text.strip().split("\n"):
line = line.strip() line = line.strip()
if not line or line.startswith('#'): if not line or line.startswith("#"):
continue continue
parts = split_account_line(line) parts = split_account_line(line)
if len(parts) < 4: if len(parts) < 4:
@@ -88,14 +92,16 @@ def parse_and_build_accounts(
seen_in_batch.add(username_key) seen_in_batch.add(username_key)
email_cfg = get_email_config_for_account(email) email_cfg = get_email_config_for_account(email)
accounts.append(Account( accounts.append(
username=username, Account(
password=password, username=username,
email=email, password=password,
email_password=email_password, email=email,
email_imap_server=email_cfg['server'], email_password=email_password,
email_imap_port=email_cfg.get('port', 993), email_imap_server=email_cfg["server"],
email_imap_ssl=email_cfg.get('ssl', True), email_imap_port=email_cfg.get("port", 993),
tag=tag, email_imap_ssl=email_cfg.get("ssl", True),
)) tag=tag,
)
)
return accounts, skipped, duplicated return accounts, skipped, duplicated
+14 -3
View File
@@ -12,8 +12,17 @@ from ..models import AuditLog, User
_SENSITIVE_KEY_PARTS = ( _SENSITIVE_KEY_PARTS = (
"cookie", "token", "password", "passwd", "secret", "signature", "sign", "cookie",
"qr_data", "authorization", "credential", "private_key", "token",
"password",
"passwd",
"secret",
"signature",
"sign",
"qr_data",
"authorization",
"credential",
"private_key",
) )
@@ -42,7 +51,9 @@ def record_audit(
) -> AuditLog: ) -> AuditLog:
"""加入一条审计记录;调用方负责与业务变更一起提交事务。""" """加入一条审计记录;调用方负责与业务变更一起提交事务。"""
if isinstance(detail, Mapping): if isinstance(detail, Mapping):
detail_text = json.dumps(_safe_value(detail), ensure_ascii=False, separators=(",", ":")) detail_text = json.dumps(
_safe_value(detail), ensure_ascii=False, separators=(",", ":")
)
elif detail is None: elif detail is None:
detail_text = "" detail_text = ""
else: else:
+124 -57
View File
@@ -200,7 +200,9 @@ def snapshot_from_batch(batch: HuyaRegisterBatch) -> dict:
} }
def load_batch_snapshot(batch_id: str, *, recover_interrupted: bool = True) -> dict | None: def load_batch_snapshot(
batch_id: str, *, recover_interrupted: bool = True
) -> dict | None:
"""从数据库加载批次详情;若服务中断则标记为 interrupted。""" """从数据库加载批次详情;若服务中断则标记为 interrupted。"""
db = SessionLocal() db = SessionLocal()
try: try:
@@ -246,7 +248,9 @@ def load_batch_snapshot(batch_id: str, *, recover_interrupted: bool = True) -> d
db.close() db.close()
def list_batch_summaries(limit: int = 50, live_batch_ids: set[str] | None = None) -> list[dict]: def list_batch_summaries(
limit: int = 50, live_batch_ids: set[str] | None = None
) -> list[dict]:
"""列出最近的注册批次摘要。live_batch_ids 中的 running 保持运行中。""" """列出最近的注册批次摘要。live_batch_ids 中的 running 保持运行中。"""
live = live_batch_ids or set() live = live_batch_ids or set()
db = SessionLocal() db = SessionLocal()
@@ -269,42 +273,51 @@ def list_batch_summaries(limit: int = 50, live_batch_ids: set[str] | None = None
if status == "running": if status == "running":
running_count = max( running_count = max(
0, 0,
int(row.total or 0) - int(row.success_count or 0) - int(row.failed_count or 0) - int(row.stopped_count or 0), int(row.total or 0)
- int(row.success_count or 0)
- int(row.failed_count or 0)
- int(row.stopped_count or 0),
) )
result.append({ result.append(
"batch_id": row.batch_id, {
"status": status, "batch_id": row.batch_id,
"message": message, "status": status,
"tag": row.tag or "", "message": message,
"created_by": row.created_by, "tag": row.tag or "",
"concurrency": int(row.concurrency or 1), "created_by": row.created_by,
"wait_seconds": float(row.wait_seconds or 180), "concurrency": int(row.concurrency or 1),
"poll_interval": float(row.poll_interval or 5), "wait_seconds": float(row.wait_seconds or 180),
"password_prefix": row.password_prefix or "hy", "poll_interval": float(row.poll_interval or 5),
"use_proxy": bool(row.use_proxy), "password_prefix": row.password_prefix or "hy",
"total": int(row.total or 0), "use_proxy": bool(row.use_proxy),
"success_count": int(row.success_count or 0), "total": int(row.total or 0),
"failed_count": int(row.failed_count or 0), "success_count": int(row.success_count or 0),
"stopped_count": int(row.stopped_count or 0), "failed_count": int(row.failed_count or 0),
"running_count": running_count, "stopped_count": int(row.stopped_count or 0),
"created_at": row.created_at, "running_count": running_count,
"started_at": row.started_at, "created_at": row.created_at,
"finished_at": row.finished_at, "started_at": row.started_at,
"items": [], "finished_at": row.finished_at,
}) "items": [],
}
)
return result return result
finally: finally:
db.close() db.close()
def _refresh_batch_counts(batch_row: HuyaRegisterBatchModel, items: list[HuyaRegisterItemModel]): def _refresh_batch_counts(
batch_row: HuyaRegisterBatchModel, items: list[HuyaRegisterItemModel]
):
batch_row.total = len(items) batch_row.total = len(items)
batch_row.success_count = sum(1 for item in items if item.status == "success") batch_row.success_count = sum(1 for item in items if item.status == "success")
batch_row.failed_count = sum(1 for item in items if item.status == "error") batch_row.failed_count = sum(1 for item in items if item.status == "error")
batch_row.stopped_count = sum(1 for item in items if item.status == "stopped") batch_row.stopped_count = sum(1 for item in items if item.status == "stopped")
def format_success_export_line(username: str, uid: str, password: str, phone: str, sms_url: str) -> str: def format_success_export_line(
username: str, uid: str, password: str, phone: str, sms_url: str
) -> str:
"""统一成功导出格式。""" """统一成功导出格式。"""
account = (username or uid or "").strip() account = (username or uid or "").strip()
return f"{account}----{password or ''}----{phone or ''}----{sms_url or ''}" return f"{account}----{password or ''}----{phone or ''}----{sms_url or ''}"
@@ -319,7 +332,9 @@ def export_success_logs_text(
"""从成功流水表导出 txt。""" """从成功流水表导出 txt。"""
db = SessionLocal() db = SessionLocal()
try: try:
query = db.query(HuyaRegisterSuccessLog).order_by(HuyaRegisterSuccessLog.id.asc()) query = db.query(HuyaRegisterSuccessLog).order_by(
HuyaRegisterSuccessLog.id.asc()
)
if batch_id: if batch_id:
query = query.filter(HuyaRegisterSuccessLog.batch_id == batch_id) query = query.filter(HuyaRegisterSuccessLog.batch_id == batch_id)
if tag: if tag:
@@ -350,7 +365,9 @@ def list_success_logs(
"""列出成功流水(含密码,供管理端展示/导出)。""" """列出成功流水(含密码,供管理端展示/导出)。"""
db = SessionLocal() db = SessionLocal()
try: try:
query = db.query(HuyaRegisterSuccessLog).order_by(HuyaRegisterSuccessLog.id.desc()) query = db.query(HuyaRegisterSuccessLog).order_by(
HuyaRegisterSuccessLog.id.desc()
)
if batch_id: if batch_id:
query = query.filter(HuyaRegisterSuccessLog.batch_id == batch_id) query = query.filter(HuyaRegisterSuccessLog.batch_id == batch_id)
if tag: if tag:
@@ -406,7 +423,11 @@ class HuyaRegisterRunner:
def _create_proxy_fetcher(self) -> ProxyFetcher | None: def _create_proxy_fetcher(self) -> ProxyFetcher | None:
"""按需创建 API 代理获取器。""" """按需创建 API 代理获取器。"""
if not self.batch.use_proxy or not self.proxy_config or not self.proxy_config.enabled: if (
not self.batch.use_proxy
or not self.proxy_config
or not self.proxy_config.enabled
):
return None return None
if not self.proxy_config.api_url: if not self.proxy_config.api_url:
return None return None
@@ -414,9 +435,15 @@ class HuyaRegisterRunner:
wl_platform = "xiequ" wl_platform = "xiequ"
wl_credentials = None wl_credentials = None
if self.proxy_config.whitelist_enabled: if self.proxy_config.whitelist_enabled:
wl_platform = getattr(self.proxy_config, "whitelist_platform", None) or "xiequ" wl_platform = (
getattr(self.proxy_config, "whitelist_platform", None) or "xiequ"
)
wl_credentials = getattr(self.proxy_config, "whitelist_credentials", None) wl_credentials = getattr(self.proxy_config, "whitelist_credentials", None)
if not wl_credentials and self.proxy_config.whitelist_uid and self.proxy_config.whitelist_ukey: if (
not wl_credentials
and self.proxy_config.whitelist_uid
and self.proxy_config.whitelist_ukey
):
wl_credentials = { wl_credentials = {
"uid": self.proxy_config.whitelist_uid, "uid": self.proxy_config.whitelist_uid,
"ukey": self.proxy_config.whitelist_ukey, "ukey": self.proxy_config.whitelist_ukey,
@@ -446,7 +473,11 @@ class HuyaRegisterRunner:
return return
db = SessionLocal() db = SessionLocal()
try: try:
row = db.query(HuyaRegisterBatchModel).filter(HuyaRegisterBatchModel.id == self.batch.db_id).first() row = (
db.query(HuyaRegisterBatchModel)
.filter(HuyaRegisterBatchModel.id == self.batch.db_id)
.first()
)
if not row: if not row:
return return
row.status = self.batch.status row.status = self.batch.status
@@ -485,7 +516,11 @@ class HuyaRegisterRunner:
return return
db = SessionLocal() db = SessionLocal()
try: try:
row = db.query(HuyaRegisterItemModel).filter(HuyaRegisterItemModel.id == item.db_id).first() row = (
db.query(HuyaRegisterItemModel)
.filter(HuyaRegisterItemModel.id == item.db_id)
.first()
)
if not row: if not row:
return return
row.status = item.status row.status = item.status
@@ -518,12 +553,16 @@ class HuyaRegisterRunner:
setattr(item, key, value) setattr(item, key, value)
self._persist_item(index) self._persist_item(index)
def _save_success(self, index: int, result: HuyaAutoRegisterResult) -> tuple[int | None, str, str]: def _save_success(
self, index: int, result: HuyaAutoRegisterResult
) -> tuple[int | None, str, str]:
"""成功时:写账号 + 成功流水(成功一个写一条,立即可导出)。""" """成功时:写账号 + 成功流水(成功一个写一条,立即可导出)。"""
db = SessionLocal() db = SessionLocal()
try: try:
# upsert_huya_cookie 内部会 commit 一次 # upsert_huya_cookie 内部会 commit 一次
account = upsert_huya_cookie(db, result.cookie, tag=self.batch.tag, username_hint="") account = upsert_huya_cookie(
db, result.cookie, tag=self.batch.tag, username_hint=""
)
account.game_phone = result.phone or account.game_phone or "" account.game_phone = result.phone or account.game_phone or ""
if result.username: if result.username:
account.username = result.username account.username = result.username
@@ -537,23 +576,29 @@ class HuyaRegisterRunner:
account.updated_at = _now() account.updated_at = _now()
item = self.batch.items[index] item = self.batch.items[index]
db.add(HuyaRegisterSuccessLog( db.add(
batch_id=self.batch.batch_id, HuyaRegisterSuccessLog(
item_id=item.db_id, batch_id=self.batch.batch_id,
account_id=account.id, item_id=item.db_id,
phone=result.phone or item.phone, account_id=account.id,
username=result.username or account.username or "", phone=result.phone or item.phone,
uid=result.uid or account.uid or account.yyuid or "", username=result.username or account.username or "",
password=result.password or "", uid=result.uid or account.uid or account.yyuid or "",
sms_url=sms_url, password=result.password or "",
tag=self.batch.tag, sms_url=sms_url,
provider=result.provider or item.provider, tag=self.batch.tag,
created_by=self.batch.created_by, provider=result.provider or item.provider,
created_at=_now(), created_by=self.batch.created_by,
)) created_at=_now(),
)
)
db.commit() db.commit()
db.refresh(account) db.refresh(account)
return account.id, account.username or "", account.uid or account.yyuid or "" return (
account.id,
account.username or "",
account.uid or account.yyuid or "",
)
finally: finally:
db.close() db.close()
@@ -578,12 +623,16 @@ class HuyaRegisterRunner:
def _run_one(self, index: int, item: SmsLine): def _run_one(self, index: int, item: SmsLine):
if self._stop.is_set(): if self._stop.is_set():
self._set_item(index, status="stopped", message="已停止", finished_at=_now()) self._set_item(
index, status="stopped", message="已停止", finished_at=_now()
)
return return
proxies, proxy_error = self._resolve_proxy() proxies, proxy_error = self._resolve_proxy()
if proxy_error: if proxy_error:
self._set_item(index, status="error", message=proxy_error, finished_at=_now()) self._set_item(
index, status="error", message=proxy_error, finished_at=_now()
)
return return
self._set_item( self._set_item(
@@ -714,7 +763,12 @@ class HuyaRegisterRunner:
futures = [] futures = []
for index in indices: for index in indices:
if self._stop.is_set(): if self._stop.is_set():
self._set_item(index, status="stopped", message="已停止", finished_at=_now()) self._set_item(
index,
status="stopped",
message="已停止",
finished_at=_now(),
)
continue continue
item = self.sms_lines[index] item = self.sms_lines[index]
futures.append(executor.submit(self._run_one, index, item)) futures.append(executor.submit(self._run_one, index, item))
@@ -841,7 +895,11 @@ class HuyaRegisterRegistry:
# DB 同步为 running,避免返回 pending 导致前端误判 # DB 同步为 running,避免返回 pending 导致前端误判
db = SessionLocal() db = SessionLocal()
try: try:
row = db.query(HuyaRegisterBatchModel).filter(HuyaRegisterBatchModel.id == db_id).first() row = (
db.query(HuyaRegisterBatchModel)
.filter(HuyaRegisterBatchModel.id == db_id)
.first()
)
if row: if row:
row.status = "running" row.status = "running"
row.message = "批次运行中" row.message = "批次运行中"
@@ -850,7 +908,9 @@ class HuyaRegisterRegistry:
finally: finally:
db.close() db.close()
runner = HuyaRegisterRunner(batch=batch, sms_lines=sms_lines, proxy_config=proxy_config) runner = HuyaRegisterRunner(
batch=batch, sms_lines=sms_lines, proxy_config=proxy_config
)
with self._lock: with self._lock:
self._runners[batch_id] = runner self._runners[batch_id] = runner
return runner return runner
@@ -930,7 +990,9 @@ class HuyaRegisterRegistry:
if poll_interval is not None: if poll_interval is not None:
batch_row.poll_interval = int(max(1.0, float(poll_interval))) batch_row.poll_interval = int(max(1.0, float(poll_interval)))
if password_prefix is not None: if password_prefix is not None:
batch_row.password_prefix = (password_prefix or "hy").strip()[:8] or "hy" batch_row.password_prefix = (password_prefix or "hy").strip()[
:8
] or "hy"
if fixed_password is not None: if fixed_password is not None:
batch_row.fixed_password = (fixed_password or "").strip() batch_row.fixed_password = (fixed_password or "").strip()
if use_proxy is not None: if use_proxy is not None:
@@ -965,7 +1027,12 @@ class HuyaRegisterRegistry:
batch = _batch_from_db(batch_row, item_rows) batch = _batch_from_db(batch_row, item_rows)
sms_lines = [ sms_lines = [
SmsLine(phone=item.phone, url=item.sms_url, provider=item.provider, raw=f"{item.phone}----{item.sms_url}") SmsLine(
phone=item.phone,
url=item.sms_url,
provider=item.provider,
raw=f"{item.phone}----{item.sms_url}",
)
for item in batch.items for item in batch.items
] ]
finally: finally:
+64 -31
View File
@@ -19,15 +19,24 @@ from .huya_runner_recharge import RechargeMixin
class HuyaBatchRunner( class HuyaBatchRunner(
HuyaBatchRunnerCore, BindMixin, GoodsMixin, RechargeMixin, HuyaBatchRunnerCore,
BindMixin,
GoodsMixin,
RechargeMixin,
): ):
"""批量执行虎牙任务(功能域 Mixin 聚合 + 批次调度)。""" """批量执行虎牙任务(功能域 Mixin 聚合 + 批次调度)。"""
def _execute_one(self, task_id: int, account_info: dict, config_info: dict, total: int): def _execute_one(
self, task_id: int, account_info: dict, config_info: dict, total: int
):
worker_db = SessionLocal() worker_db = SessionLocal()
try: try:
task = worker_db.query(HuyaTask).filter(HuyaTask.id == task_id).first() task = worker_db.query(HuyaTask).filter(HuyaTask.id == task_id).first()
account = worker_db.query(HuyaAccount).filter(HuyaAccount.id == account_info["account_id"]).first() account = (
worker_db.query(HuyaAccount)
.filter(HuyaAccount.id == account_info["account_id"])
.first()
)
if not task or not account: if not task or not account:
return return
@@ -59,28 +68,48 @@ class HuyaBatchRunner(
"create_recharge_order", "create_recharge_order",
}: }:
self._mark_task(worker_db, task, "failed", "该虎牙任务执行器暂未实现") self._mark_task(worker_db, task, "failed", "该虎牙任务执行器暂未实现")
self._push_log("warning", f"[{current}] {name} 暂未实现: {self.task_type}") self._push_log(
"warning", f"[{current}] {name} 暂未实现: {self.task_type}"
)
return return
try: try:
if self.task_type == "query_points": if self.task_type == "query_points":
self._execute_query_points(worker_db, task, account, account_info, config_info) self._execute_query_points(
worker_db, task, account, account_info, config_info
)
elif self.task_type == "refresh_goods": elif self.task_type == "refresh_goods":
self._execute_refresh_goods(worker_db, task, account, account_info, config_info) self._execute_refresh_goods(
worker_db, task, account, account_info, config_info
)
elif self.task_type == "refresh_recharge_goods": elif self.task_type == "refresh_recharge_goods":
self._execute_refresh_recharge_goods(worker_db, task, account, account_info, config_info) self._execute_refresh_recharge_goods(
worker_db, task, account, account_info, config_info
)
elif self.task_type == "exchange_goods": elif self.task_type == "exchange_goods":
self._execute_exchange_goods(worker_db, task, account, account_info, config_info) self._execute_exchange_goods(
worker_db, task, account, account_info, config_info
)
elif self.task_type == "create_recharge_order": elif self.task_type == "create_recharge_order":
self._execute_create_recharge_order(worker_db, task, account, account_info, config_info) self._execute_create_recharge_order(
worker_db, task, account, account_info, config_info
)
elif self.task_type == "get_bind_qr": elif self.task_type == "get_bind_qr":
self._execute_get_bind_qr(worker_db, task, account, account_info, config_info) self._execute_get_bind_qr(
worker_db, task, account, account_info, config_info
)
elif self.task_type == "confirm_bind": elif self.task_type == "confirm_bind":
self._execute_confirm_bind(worker_db, task, account, account_info, config_info) self._execute_confirm_bind(
worker_db, task, account, account_info, config_info
)
elif self.task_type == "query_game_name": elif self.task_type == "query_game_name":
self._execute_query_game_name(worker_db, task, account, account_info, config_info) self._execute_query_game_name(
worker_db, task, account, account_info, config_info
)
elif self.task_type == "query_exchange_records": elif self.task_type == "query_exchange_records":
self._execute_query_exchange_records(worker_db, task, account, account_info, config_info) self._execute_query_exchange_records(
worker_db, task, account, account_info, config_info
)
worker_db.refresh(task) worker_db.refresh(task)
if task.status == "success": if task.status == "success":
self._push_log("success", f"[{current}] {name} {task.message}") self._push_log("success", f"[{current}] {name} {task.message}")
@@ -124,17 +153,19 @@ class HuyaBatchRunner(
task.status = "pending" task.status = "pending"
task.message = "等待执行" task.message = "等待执行"
task.finished_at = None task.finished_at = None
task_infos.append({ task_infos.append(
"task_id": task.id, {
"account_info": { "task_id": task.id,
"account_id": account.id, "account_info": {
"uid": account.uid or "", "account_id": account.id,
"yyuid": account.yyuid or "", "uid": account.uid or "",
"username": account.username or "", "yyuid": account.yyuid or "",
"nickname": account.nickname or "", "username": account.username or "",
"cookie": normalize_huya_cookie(account.cookie or ""), "nickname": account.nickname or "",
}, "cookie": normalize_huya_cookie(account.cookie or ""),
}) },
}
)
self.db.commit() self.db.commit()
total = len(task_infos) total = len(task_infos)
@@ -149,13 +180,15 @@ class HuyaBatchRunner(
if self._stop.is_set(): if self._stop.is_set():
self._push_log("warning", "任务已停止,跳过剩余账号") self._push_log("warning", "任务已停止,跳过剩余账号")
break break
futures.append(executor.submit( futures.append(
self._execute_one, executor.submit(
item["task_id"], self._execute_one,
item["account_info"], item["task_id"],
config_info, item["account_info"],
total, config_info,
)) total,
)
)
for future in as_completed(futures): for future in as_completed(futures):
try: try:
+4 -2
View File
@@ -20,8 +20,10 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from .huya_runner import HuyaBatchRunner from .huya_runner import HuyaBatchRunner
class HuyaBatchRunnerCore: class HuyaBatchRunnerCore:
"""虎牙任务执行器公共基础:批次状态、日志、任务落库。""" """虎牙任务执行器公共基础:批次状态、日志、任务落库。"""
def __init__( def __init__(
self, self,
db: Session, db: Session,
@@ -156,7 +158,8 @@ class HuyaBatchRegistry:
expired = [ expired = [
batch_id batch_id
for batch_id, batch in self._batches.items() for batch_id, batch in self._batches.items()
if batch.get("finished") and now - float(batch.get("finished_at") or now) > ttl_seconds if batch.get("finished")
and now - float(batch.get("finished_at") or now) > ttl_seconds
] ]
for batch_id in expired: for batch_id in expired:
self._batches.pop(batch_id, None) self._batches.pop(batch_id, None)
@@ -206,4 +209,3 @@ class HuyaBatchRegistry:
huya_batch_registry = HuyaBatchRegistry() huya_batch_registry = HuyaBatchRegistry()
+35 -16
View File
@@ -37,7 +37,10 @@ def apply_huya_config_defaults(config: HuyaConfig) -> bool:
"""补齐虎牙配置默认值,返回是否发生变更。""" """补齐虎牙配置默认值,返回是否发生变更。"""
changed = False changed = False
for field in HUYA_CONFIG_FIELDS: for field in HUYA_CONFIG_FIELDS:
if field == "bind_act_id" and str(getattr(config, field, "") or "").strip() == "17096": if (
field == "bind_act_id"
and str(getattr(config, field, "") or "").strip() == "17096"
):
setattr(config, field, HUYA_CONFIG_DEFAULTS[field]) setattr(config, field, HUYA_CONFIG_DEFAULTS[field])
changed = True changed = True
continue continue
@@ -160,7 +163,9 @@ def split_huya_password_line(line: str) -> HuyaPasswordLine | None:
) )
def import_huya_password_accounts(db: Session, text: str, tag: str = "") -> tuple[int, int]: def import_huya_password_accounts(
db: Session, text: str, tag: str = ""
) -> tuple[int, int]:
"""导入虎牙账号密码,返回 (导入/更新数, 跳过数)。""" """导入虎牙账号密码,返回 (导入/更新数, 跳过数)。"""
created_or_updated = 0 created_or_updated = 0
skipped = 0 skipped = 0
@@ -207,13 +212,17 @@ def import_huya_password_accounts(db: Session, text: str, tag: str = "") -> tupl
return created_or_updated, skipped return created_or_updated, skipped
def _upsert_huya_account(db: Session, parsed: dict, tag: str = "", status: str | None = None) -> HuyaAccount: def _upsert_huya_account(
db: Session, parsed: dict, tag: str = "", status: str | None = None
) -> HuyaAccount:
"""按 uid/yyuid 新增或更新虎牙账号。""" """按 uid/yyuid 新增或更新虎牙账号。"""
account = None account = None
if parsed["uid"]: if parsed["uid"]:
account = db.query(HuyaAccount).filter(HuyaAccount.uid == parsed["uid"]).first() account = db.query(HuyaAccount).filter(HuyaAccount.uid == parsed["uid"]).first()
if account is None and parsed["yyuid"]: if account is None and parsed["yyuid"]:
account = db.query(HuyaAccount).filter(HuyaAccount.yyuid == parsed["yyuid"]).first() account = (
db.query(HuyaAccount).filter(HuyaAccount.yyuid == parsed["yyuid"]).first()
)
if account is None: if account is None:
account = HuyaAccount( account = HuyaAccount(
@@ -273,13 +282,17 @@ def save_huya_login_cookie_to_account(
return account return account
def upsert_huya_cookie(db: Session, cookie: str, tag: str = "", username_hint: str = "") -> HuyaAccount: def upsert_huya_cookie(
db: Session, cookie: str, tag: str = "", username_hint: str = ""
) -> HuyaAccount:
"""保存单条登录得到的虎牙 Cookie。""" """保存单条登录得到的虎牙 Cookie。"""
line = f"{username_hint}----{cookie}" if username_hint else cookie line = f"{username_hint}----{cookie}" if username_hint else cookie
parsed = parse_huya_cookie_line(line) parsed = parse_huya_cookie_line(line)
if not parsed: if not parsed:
raise ValueError("登录成功但 Cookie 中没有识别到虎牙 uid") raise ValueError("登录成功但 Cookie 中没有识别到虎牙 uid")
account = _upsert_huya_account(db, parsed, tag=(tag or "").strip(), status="login_success") account = _upsert_huya_account(
db, parsed, tag=(tag or "").strip(), status="login_success"
)
db.commit() db.commit()
db.refresh(account) db.refresh(account)
return account return account
@@ -368,18 +381,24 @@ def create_planned_tasks(
batch_id = uuid.uuid4().hex[:12] batch_id = uuid.uuid4().hex[:12]
payload = payload or {} payload = payload or {}
accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all() accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all()
if task_type in {"refresh_goods", "refresh_recharge_goods", "create_recharge_order"} and accounts: if (
task_type
in {"refresh_goods", "refresh_recharge_goods", "create_recharge_order"}
and accounts
):
# 全局快照和单笔支付二维码使用一个选中的 CK 即可;兑换商品需要保留多账号批量任务。 # 全局快照和单笔支付二维码使用一个选中的 CK 即可;兑换商品需要保留多账号批量任务。
accounts = accounts[:1] accounts = accounts[:1]
for account in accounts: for account in accounts:
db.add(HuyaTask( db.add(
batch_id=batch_id, HuyaTask(
account_id=account.id, batch_id=batch_id,
task_type=task_type, account_id=account.id,
status="planned", task_type=task_type,
message="任务已创建,等待执行", status="planned",
result={"payload": payload} if payload else None, message="任务已创建,等待执行",
created_by=created_by, result={"payload": payload} if payload else None,
)) created_by=created_by,
)
)
db.commit() db.commit()
return batch_id, len(accounts) return batch_id, len(accounts)
+30 -15
View File
@@ -8,7 +8,11 @@ from typing import Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response from core.douyu.proxy import (
resolve_working_proxy,
verify_proxy_url,
parse_proxy_response,
)
from core.douyu.proxy_platforms import create_adapter, get_platform_labels from core.douyu.proxy_platforms import create_adapter, get_platform_labels
from core.douyu.proxy_platforms.base import _get_local_exit_ip from core.douyu.proxy_platforms.base import _get_local_exit_ip
from ..models import ProxyConfig as ProxyConfigModel, AuditLog from ..models import ProxyConfig as ProxyConfigModel, AuditLog
@@ -20,8 +24,8 @@ def _build_whitelist_params(cfg: ProxyConfigModel) -> dict:
Returns: Returns:
{"whitelist_platform": str, "whitelist_credentials": dict|None} {"whitelist_platform": str, "whitelist_credentials": dict|None}
""" """
platform = getattr(cfg, 'whitelist_platform', None) or "xiequ" platform = getattr(cfg, "whitelist_platform", None) or "xiequ"
credentials = getattr(cfg, 'whitelist_credentials', None) credentials = getattr(cfg, "whitelist_credentials", None)
# 向后兼容:旧字段有值但新字段为空时,自动迁移 # 向后兼容:旧字段有值但新字段为空时,自动迁移
if not credentials and cfg.whitelist_uid and cfg.whitelist_ukey: if not credentials and cfg.whitelist_uid and cfg.whitelist_ukey:
@@ -57,7 +61,10 @@ class ProxyService:
# 应用层自动迁移:旧字段有值但新字段为空时,填充新字段 # 应用层自动迁移:旧字段有值但新字段为空时,填充新字段
if cfg.whitelist_uid and cfg.whitelist_ukey and not cfg.whitelist_credentials: if cfg.whitelist_uid and cfg.whitelist_ukey and not cfg.whitelist_credentials:
cfg.whitelist_platform = "xiequ" cfg.whitelist_platform = "xiequ"
cfg.whitelist_credentials = {"uid": cfg.whitelist_uid, "ukey": cfg.whitelist_ukey} cfg.whitelist_credentials = {
"uid": cfg.whitelist_uid,
"ukey": cfg.whitelist_ukey,
}
db.commit() db.commit()
return cfg return cfg
@@ -65,7 +72,10 @@ class ProxyService:
@staticmethod @staticmethod
def update_config( def update_config(
db: Session, db: Session,
enabled, api_url, http, https, enabled,
api_url,
http,
https,
whitelist_enabled, whitelist_enabled,
whitelist_platform="xiequ", whitelist_platform="xiequ",
whitelist_credentials=None, whitelist_credentials=None,
@@ -96,12 +106,14 @@ class ProxyService:
db.refresh(cfg) db.refresh(cfg)
if current_user: if current_user:
db.add(AuditLog( db.add(
user_id=current_user.id, AuditLog(
username=current_user.username, user_id=current_user.id,
action="proxy:update", username=current_user.username,
target="proxy_config", action="proxy:update",
)) target="proxy_config",
)
)
db.commit() db.commit()
return cfg return cfg
@@ -258,18 +270,21 @@ class ProxyService:
# 3. 检查并同步白名单 # 3. 检查并同步白名单
records = adapter.get_whitelist() records = adapter.get_whitelist()
in_list = any(r.get('ip') == local_ip for r in records) in_list = any(r.get("ip") == local_ip for r in records)
if records: if records:
push("info", f"白名单共 {len(records)} 条记录") push("info", f"白名单共 {len(records)} 条记录")
if in_list: if in_list:
record = next((r for r in records if r.get('ip') == local_ip), {}) record = next((r for r in records if r.get("ip") == local_ip), {})
memo = record.get('memo', '') memo = record.get("memo", "")
if memo == adapter.memo: if memo == adapter.memo:
push("success", f"本机IP {local_ip} 已在白名单中 (备注正确)") push("success", f"本机IP {local_ip} 已在白名单中 (备注正确)")
else: else:
push("warning", f'本机IP {local_ip} 备注不匹配 (当前: "{memo}"),更新中...') push(
"warning",
f'本机IP {local_ip} 备注不匹配 (当前: "{memo}"),更新中...',
)
sync_ok, sync_msg = adapter.sync_ip(local_ip) sync_ok, sync_msg = adapter.sync_ip(local_ip)
push("success" if sync_ok else "error", f"白名单更新: {sync_msg}") push("success" if sync_ok else "error", f"白名单更新: {sync_msg}")
else: else:
+61 -19
View File
@@ -32,9 +32,22 @@ def _as_utc(value: datetime | None) -> datetime | None:
def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int: def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int:
rows = db.query(YybRechargeTask).filter( rows = (
YybRechargeTask.status.in_(["created", "waiting_login", "ready", "running", "ordering", "waiting_payment"]) db.query(YybRechargeTask)
).all() .filter(
YybRechargeTask.status.in_(
[
"created",
"waiting_login",
"ready",
"running",
"ordering",
"waiting_payment",
]
)
)
.all()
)
for task in rows: for task in rows:
if task.status == "ordering": if task.status == "ordering":
task.status = "waiting_payment" task.status = "waiting_payment"
@@ -50,7 +63,9 @@ def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int:
return len(rows) return len(rows)
def sync_task(db: Session, task: YybRechargeTask, worker: YybWorkerClient) -> YybRechargeTask: def sync_task(
db: Session, task: YybRechargeTask, worker: YybWorkerClient
) -> YybRechargeTask:
data = worker.get_job(task.worker_job_id) data = worker.get_job(task.worker_job_id)
task.status = str(data.get("status", task.status)) task.status = str(data.get("status", task.status))
task.phase = str(data.get("phase", task.phase)) task.phase = str(data.get("phase", task.phase))
@@ -59,10 +74,16 @@ def sync_task(db: Session, task: YybRechargeTask, worker: YybWorkerClient) -> Yy
task.provider = str(data["provider"]) task.provider = str(data["provider"])
if data.get("qr_data"): if data.get("qr_data"):
task.login_qr_data = str(data["qr_data"]) task.login_qr_data = str(data["qr_data"])
task.result = {**(task.result or {}), "login_qr_mime_type": data.get("qr_mime_type", "image/jpeg")} task.result = {
**(task.result or {}),
"login_qr_mime_type": data.get("qr_mime_type", "image/jpeg"),
}
if data.get("payment_qr_data"): if data.get("payment_qr_data"):
task.payment_qr_data = str(data["payment_qr_data"]) task.payment_qr_data = str(data["payment_qr_data"])
task.result = {**(task.result or {}), "payment_qr_mime_type": data.get("payment_qr_mime_type", "image/png")} task.result = {
**(task.result or {}),
"payment_qr_mime_type": data.get("payment_qr_mime_type", "image/png"),
}
task.result = { task.result = {
**(task.result or {}), **(task.result or {}),
"logs": data.get("logs", []), "logs": data.get("logs", []),
@@ -76,32 +97,53 @@ def sync_task(db: Session, task: YybRechargeTask, worker: YybWorkerClient) -> Yy
task.payment_last_checked_at = last_checked_at task.payment_last_checked_at = last_checked_at
if task.status in {"success", "failed"} and task.finished_at is None: if task.status in {"success", "failed"} and task.finished_at is None:
task.finished_at = _utcnow() task.finished_at = _utcnow()
if task.status not in {"success", "failed", "stopped"} and task.finished_at is not None: if (
task.status not in {"success", "failed", "stopped"}
and task.finished_at is not None
):
task.finished_at = None task.finished_at = None
db.commit() db.commit()
db.refresh(task) db.refresh(task)
return task return task
def public_task(task: YybRechargeTask, include_qr: bool = True, def public_task(
include_payment_qr: bool | None = None, task: YybRechargeTask,
creator_username: str = "") -> dict[str, Any]: include_qr: bool = True,
include_payment_qr: bool | None = None,
creator_username: str = "",
) -> dict[str, Any]:
result: dict[str, Any] = { result: dict[str, Any] = {
"id": task.id, "task_id": task.task_id, "id": task.id,
"provider": task.provider, "platform": task.platform, "points": task.points, "task_id": task.task_id,
"provider": task.provider,
"platform": task.platform,
"points": task.points,
"price_fen": task.price_fen, "price_fen": task.price_fen,
"product_id": task.product_id, "zone_id": task.zone_id, "zone_name": task.zone_name, "product_id": task.product_id,
"role_id": task.role_id, "role_name": task.role_name, "status": task.status, "zone_id": task.zone_id,
"phase": task.phase, "message": task.message, "result": task.result, "zone_name": task.zone_name,
"created_by": task.created_by, "created_by_username": creator_username, "role_id": task.role_id,
"created_at": _as_utc(task.created_at), "finished_at": _as_utc(task.finished_at), "role_name": task.role_name,
"status": task.status,
"phase": task.phase,
"message": task.message,
"result": task.result,
"created_by": task.created_by,
"created_by_username": creator_username,
"created_at": _as_utc(task.created_at),
"finished_at": _as_utc(task.finished_at),
"payment_started_at": _as_utc(task.payment_started_at), "payment_started_at": _as_utc(task.payment_started_at),
"payment_qr_created_at": _as_utc(task.payment_qr_created_at), "payment_qr_created_at": _as_utc(task.payment_qr_created_at),
"payment_last_checked_at": _as_utc(task.payment_last_checked_at), "payment_last_checked_at": _as_utc(task.payment_last_checked_at),
} }
if task.result: if task.result:
result["login_qr_mime_type"] = task.result.get("login_qr_mime_type", "image/jpeg") result["login_qr_mime_type"] = task.result.get(
result["payment_qr_mime_type"] = task.result.get("payment_qr_mime_type", "image/png") "login_qr_mime_type", "image/jpeg"
)
result["payment_qr_mime_type"] = task.result.get(
"payment_qr_mime_type", "image/png"
)
if include_payment_qr is None: if include_payment_qr is None:
include_payment_qr = include_qr include_payment_qr = include_qr
if include_qr: if include_qr:
+33 -11
View File
@@ -18,13 +18,20 @@ class YybWorkerClient:
self.key = os.getenv("YYB_WORKER_KEY", "") self.key = os.getenv("YYB_WORKER_KEY", "")
self.timeout = float(os.getenv("YYB_WORKER_TIMEOUT", "30")) self.timeout = float(os.getenv("YYB_WORKER_TIMEOUT", "30"))
def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: def _request(
self, method: str, path: str, payload: dict[str, Any] | None = None
) -> dict[str, Any]:
headers = {"Accept": "application/json"} headers = {"Accept": "application/json"}
if self.key: if self.key:
headers["Authorization"] = f"Bearer {self.key}" headers["Authorization"] = f"Bearer {self.key}"
try: try:
response = requests.request(method, self.base_url + path, json=payload, response = requests.request(
headers=headers, timeout=self.timeout) method,
self.base_url + path,
json=payload,
headers=headers,
timeout=self.timeout,
)
data = response.json() data = response.json()
except (requests.RequestException, ValueError) as exc: except (requests.RequestException, ValueError) as exc:
raise YybWorkerError(f"应用宝 Worker 不可用: {exc}") from exc raise YybWorkerError(f"应用宝 Worker 不可用: {exc}") from exc
@@ -35,19 +42,34 @@ class YybWorkerClient:
def create_job(self) -> dict[str, Any]: def create_job(self) -> dict[str, Any]:
return self._request("POST", "/v1/jobs") return self._request("POST", "/v1/jobs")
def login(self, worker_job_id: str, provider: str, timeout: int = 600) -> dict[str, Any]: def login(
return self._request("POST", f"/v1/jobs/{worker_job_id}/login", self, worker_job_id: str, provider: str, timeout: int = 600
{"provider": provider, "timeout": timeout}) ) -> dict[str, Any]:
return self._request(
"POST",
f"/v1/jobs/{worker_job_id}/login",
{"provider": provider, "timeout": timeout},
)
def get_job(self, worker_job_id: str) -> dict[str, Any]: def get_job(self, worker_job_id: str) -> dict[str, Any]:
return self._request("GET", f"/v1/jobs/{worker_job_id}") return self._request("GET", f"/v1/jobs/{worker_job_id}")
def selection_options(self, worker_job_id: str, platform: str, def selection_options(
points: int | None = None, zone_id: str | None = None) -> dict[str, Any]: self,
return self._request("POST", f"/v1/jobs/{worker_job_id}/selection-options", worker_job_id: str,
{"platform": platform, "points": points, "zone_id": zone_id}) platform: str,
points: int | None = None,
zone_id: str | None = None,
) -> dict[str, Any]:
return self._request(
"POST",
f"/v1/jobs/{worker_job_id}/selection-options",
{"platform": platform, "points": points, "zone_id": zone_id},
)
def selection(self, worker_job_id: str, selection: dict[str, Any]) -> dict[str, Any]: def selection(
self, worker_job_id: str, selection: dict[str, Any]
) -> dict[str, Any]:
return self._request("POST", f"/v1/jobs/{worker_job_id}/selection", selection) return self._request("POST", f"/v1/jobs/{worker_job_id}/selection", selection)
def payment(self, worker_job_id: str) -> dict[str, Any]: def payment(self, worker_job_id: str) -> dict[str, Any]: