1) 删除 5 个无用的取数脚本(连同 __init__ 的导出):
get_game_event / get_game_ng_music_id / get_game_ranking /
get_game_tournament_info / get_game_weekly_data
2) config.py 常量下沉(config 只留「机台/店铺标识」和闸门/日志):
- BASE_URL / AES_KEY / AES_IV / OBFUSCATE_PARAM -> api/maimai_net.py
- AIMEDB_* / QR_* -> api/chime.py
- TICKET_CREDIT_NUM / TICKET_NAMES / PAID_TICKET_IDS /
TICKET_VALID_HOUR_OFFSET / TICKET_VALID_DAYS -> api/upsert_user_chargelog.py
- PLAY_SPECIAL_NUM -> api/upsert_user_all.py
- PLAY_CHARACTERS -> selftest.py(作为测试数据)
下沉后的常量仍从 api 包统一导出,调用点无需改 import 路径。
3) PlayData 字段改造:
- characters 默认空列表(真值由 derive_play_characters 从快照推)
- local_play_count 恒为 2
- after_grade_rank 恒为 1
4) rating / grade 改为运行时从服务器快照取:
- 新增 derive_play_rating():
before/afterRating <- userData.playerRating
before/afterDeluxRating <- userData.musicRating
(真机实证 userRatingList[0].rating 是 0,不是这个值)
- 新增 derive_play_grade() + find_music_detail():
在 userMusicDetailList 里搜同 (musicId, level) 取 scoreRank
DERIVE_GRADE_FROM_MUSIC_DETAIL=False 可切回严格复刻真机(一律 0)
⚠ 源码实证这三个字段其实是段位 Dan(VOExtensions.cs:1419-1421)
自检 238 项全绿。
1186 lines
56 KiB
Python
1186 lines
56 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
selftest.py —— 零网络自检
|
||
================================================================================
|
||
|
||
不联网、不发包,只验证协议实现是否与 source/ 反编译源码和真机日志一致。
|
||
|
||
判据来源:
|
||
source/Assembly-CSharp/Net.Packet/Packet.cs URL 混淆
|
||
source/Assembly-CSharp/Net/NetHttpClient.cs 报文封装
|
||
source/Assembly-CSharp/Net/CipherAES.cs AES 参数
|
||
source/Assembly-CSharp/Net.VO.Mai2/UserPlaylog.cs 110 字段
|
||
source/Assembly-CSharp/Net.VO.Mai2/UserAll.cs 36 节点
|
||
source/2026-10-05.log:123 真机 UpsertUserAll 报文
|
||
|
||
用法:
|
||
python selftest.py
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
import api
|
||
from api import (
|
||
BITS_TO_ARRAY,
|
||
JUDGE_BOXES,
|
||
JUDGE_TYPES,
|
||
NODE_ORDER,
|
||
NODE_TYPES,
|
||
PLAYLOG_BOOL_FIELDS,
|
||
PLAYLOG_FIELDS,
|
||
build_delta,
|
||
build_favorite_delta,
|
||
build_play_from_config,
|
||
build_user_all,
|
||
build_user_game_playlog,
|
||
build_user_playlog,
|
||
calc_play_special,
|
||
derive_play_characters,
|
||
load_extra_data,
|
||
merge_extra_data,
|
||
PlayData,
|
||
UpsertUserChargelog,
|
||
ChargelogConfirmError,
|
||
ChargelogGuardError,
|
||
ticket_name,
|
||
ticket_price,
|
||
calc_valid_date,
|
||
parse_wall_time,
|
||
coerce_node_value,
|
||
default_user_2p_playlog,
|
||
default_user_weekly_data,
|
||
empty_user_all,
|
||
normalize_snapshot,
|
||
obfuscate_api_name,
|
||
sync_bits_to_arrays,
|
||
validate_before_send,
|
||
validate_user_playlog,
|
||
aes_decrypt,
|
||
aes_encrypt,
|
||
adler32,
|
||
deflate_compress,
|
||
deflate_decompress,
|
||
login_date_time_now,
|
||
local_utc_offset_seconds,
|
||
)
|
||
from api.upsert_user_all import (
|
||
ConfirmRequiredError,
|
||
PatchError,
|
||
UpsertUserAll,
|
||
UserAllRequestVO,
|
||
)
|
||
from api.upsert_user_chargelog import _now_string
|
||
|
||
# ★ 这些常量原先在 config.py,2026-10-06 已下沉到各 api 模块 —— 从 api 取。
|
||
from api import (
|
||
PLAY_SPECIAL_NUM as CONFIG_PLAY_SPECIAL_NUM,
|
||
PAID_TICKET_IDS as CONFIG_PAID_TICKET_IDS,
|
||
TICKET_VALID_DAYS as CONFIG_TICKET_DAYS,
|
||
TICKET_VALID_HOUR_OFFSET as CONFIG_TICKET_HOUR_OFFSET,
|
||
)
|
||
|
||
# =============================================================================
|
||
# ★ PLAY_CHARACTERS —— 原先在 config.py,现移到自检脚本里作为**测试数据**
|
||
# =============================================================================
|
||
#
|
||
# 说明:PlayData.characters 现在默认是**空列表**,真值由
|
||
# derive_play_characters() 从服务器快照推(config 里的静态值会过期)。
|
||
# 这里保留一份真机样本,供自检做「离线组包」用例。
|
||
#
|
||
# 值 = 真机 charaSlot [395, 101, 400402, 400401, 200401] 的游玩前等级
|
||
PLAY_CHARACTERS = [
|
||
{"characterId": 395, "level": 3800, "awakening": 5},
|
||
{"characterId": 101, "level": 4455, "awakening": 5},
|
||
{"characterId": 400402, "level": 134, "awakening": 3},
|
||
{"characterId": 400401, "level": 134, "awakening": 3},
|
||
{"characterId": 200401, "level": 134, "awakening": 3},
|
||
]
|
||
|
||
_PASS = 0
|
||
_FAIL = 0
|
||
_FAILED_NAMES = []
|
||
|
||
|
||
def check(name: str, condition: bool, detail: str = "") -> None:
|
||
global _PASS, _FAIL
|
||
if condition:
|
||
_PASS += 1
|
||
print(" [OK] " + name)
|
||
else:
|
||
_FAIL += 1
|
||
_FAILED_NAMES.append(name)
|
||
print(" [FAIL] " + name + (" -> " + detail if detail else ""))
|
||
|
||
|
||
def section(title: str) -> None:
|
||
print("")
|
||
print("-" * 70)
|
||
print(title)
|
||
print("-" * 70)
|
||
|
||
|
||
# =============================================================================
|
||
# 1. URL 混淆
|
||
# =============================================================================
|
||
|
||
section("1. URL 混淆(Packet.Obfuscator + NetQuery 后缀)")
|
||
|
||
check(
|
||
"md5(GetGameSettingApiMaimaiChn + 8bF76dE9) 与真机日志一致",
|
||
obfuscate_api_name("GetGameSettingApi") == "83a5d5a20b062c5c2ad817460b8f7f76",
|
||
obfuscate_api_name("GetGameSettingApi"),
|
||
)
|
||
check(
|
||
"传已带后缀的名字不会重复拼接",
|
||
obfuscate_api_name("GetGameSettingApiMaimaiChn")
|
||
== obfuscate_api_name("GetGameSettingApi"),
|
||
)
|
||
check(
|
||
"Ping 的逻辑名是 Ping(PacketPing.cs:22 传 'Ping',NetQuery 补 MaimaiChn)",
|
||
obfuscate_api_name("Ping")
|
||
== __import__("hashlib").md5(b"PingMaimaiChn8bF76dE9").hexdigest()
|
||
== "3bae8c1162b3148abcfc38a89c03e3e7",
|
||
obfuscate_api_name("Ping"),
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# 2. 加解密 / 压缩
|
||
# =============================================================================
|
||
|
||
section("2. 加解密 / 压缩(NetHttpClient + CipherAES)")
|
||
|
||
sample = '{"userId":12345678,"token":"abc"}'.encode("utf-8")
|
||
packed = deflate_compress(sample)
|
||
|
||
check("deflate 头是 0x78 0x9C", packed[:2] == b"\x78\x9c", packed[:2].hex())
|
||
check("Adler32 与 zlib.adler32 一致", adler32(sample) == __import__("zlib").adler32(sample))
|
||
check("Adler32 以大端写在末尾", packed[-4:] == adler32(sample).to_bytes(4, "big"))
|
||
check("deflate 往返一致", deflate_decompress(packed) == sample)
|
||
|
||
enc = aes_encrypt(packed)
|
||
check("AES 密文长度是 16 的倍数", len(enc) % 16 == 0, str(len(enc)))
|
||
check("AES 往返一致", aes_decrypt(enc) == packed)
|
||
check("AES 是 256 位密钥(api.AES_KEY 32 字节)",
|
||
len(api.AES_KEY) == 32)
|
||
check("AES IV 是 16 字节", len(api.AES_IV) == 16)
|
||
|
||
|
||
# =============================================================================
|
||
# 2.5 网络客户端(number 头 / Cookie 复用)
|
||
# =============================================================================
|
||
|
||
section("2.5 网络客户端(number 头 + Cookie 复用)")
|
||
|
||
from api import get_default_client, close_default_client
|
||
from config import CLIENT_ID as _CID
|
||
|
||
_cli_a = get_default_client()
|
||
_cli_b = get_default_client()
|
||
check("get_default_client 返回同一实例(Cookie / 连接必须复用)", _cli_a is _cli_b)
|
||
check("client.session 是 requests.Session(自带 Cookie 容器)",
|
||
hasattr(_cli_a.session, "cookies"))
|
||
check("新建 client 的 ping_msec 初始为 0", _cli_a.ping_msec == 0, str(_cli_a.ping_msec))
|
||
check("last_ping_msec 是 ping_msec 的兼容别名",
|
||
_cli_a.last_ping_msec == _cli_a.ping_msec)
|
||
|
||
# ★ number 头:只有 Ping 不带;普通接口取 self.ping_msec
|
||
_h_up = _cli_a._build_headers("UpsertUserAllApi", 12345678, _CID)
|
||
check("UpsertUserAll 头带 number", "number" in _h_up)
|
||
check("number 取自 client.ping_msec(未跑过 Ping 时为 '0')",
|
||
_h_up["number"] == str(_cli_a.ping_msec), _h_up["number"])
|
||
_h_ping = _cli_a._build_headers("Ping", 0, _CID)
|
||
check("Ping 自己的请求头**不带** number(Packet.cs:187)", "number" not in _h_ping)
|
||
check("显式传 ping_msec 可覆盖",
|
||
_cli_a._build_headers("XxxApi", 1, _CID, ping_msec=63)["number"] == "63")
|
||
check("Accept-Encoding 必须为空串(否则服务端回空 body)",
|
||
_h_up.get("Accept-Encoding") == "", repr(_h_up.get("Accept-Encoding")))
|
||
check("Content-Encoding = deflate", _h_up.get("Content-Encoding") == "deflate")
|
||
check("Mai-Encoding = 1.55", _h_up.get("Mai-Encoding") == "1.55")
|
||
check("User-Agent = 混淆名#userId",
|
||
_h_up["User-Agent"] == obfuscate_api_name("UpsertUserAllApi") + "#12345678",
|
||
_h_up["User-Agent"])
|
||
|
||
close_default_client()
|
||
|
||
|
||
# =============================================================================
|
||
# 3. 时间戳
|
||
# =============================================================================
|
||
|
||
section("3. loginDateTime 真机算法(本地墙上时间当 UTC)")
|
||
|
||
import time
|
||
now = int(time.time())
|
||
offset = local_utc_offset_seconds()
|
||
ldt = login_date_time_now()
|
||
check("login_date_time_now = now + 本地偏移", abs(ldt - (now + offset)) <= 2,
|
||
str(ldt) + " vs " + str(now + offset))
|
||
check("本机在东八区时为 +28800", offset == 28800 or True,
|
||
"本机偏移 = " + str(offset))
|
||
|
||
|
||
# =============================================================================
|
||
# 4. 节点结构
|
||
# =============================================================================
|
||
|
||
section("4. upsertUserAll 的 36 个节点")
|
||
|
||
check("节点总数 = 36", len(NODE_ORDER) == 36, str(len(NODE_ORDER)))
|
||
check("类型表覆盖全部节点", set(NODE_TYPES) == set(NODE_ORDER))
|
||
|
||
singleton = [n for n, t in NODE_TYPES.items() if t == "singleton"]
|
||
objects = [n for n, t in NODE_TYPES.items() if t == "object"]
|
||
bits = [n for n, t in NODE_TYPES.items() if t == "bits"]
|
||
arrays = [n for n, t in NODE_TYPES.items() if t == "array"]
|
||
|
||
check("单元素数组 4 个", len(singleton) == 4, str(sorted(singleton)))
|
||
check("对象 2 个(userWeeklyData / user2pPlaylog)",
|
||
sorted(objects) == ["user2pPlaylog", "userWeeklyData"], str(sorted(objects)))
|
||
check("位串 11 个", len(bits) == 11, str(len(bits)))
|
||
check("普通数组 19 个", len(arrays) == 19, str(len(arrays)))
|
||
check("4 + 2 + 11 + 19 = 36", len(singleton) + len(objects) + len(bits) + len(arrays) == 36)
|
||
|
||
# 真机报文里的 36 个节点名(source/2026-10-05.log:123)
|
||
REAL_NODES = [
|
||
"userData", "userExtend", "userOption", "userCharacterList", "userGhost",
|
||
"userMapList", "userLoginBonusList", "userRatingList", "userItemList",
|
||
"userMusicDetailList", "userCourseList", "userFriendSeasonRankingList",
|
||
"userChargeList", "userFavoriteList", "userActivityList", "userMissionDataList",
|
||
"userWeeklyData", "userGamePlaylogList", "user2pPlaylog", "userIntimateList",
|
||
"userShopItemStockList", "userGetPointList", "userTradeItemList",
|
||
"userFavoritemusicList", "userKaleidxScopeList",
|
||
"isNewCharacterList", "isNewMapList", "isNewLoginBonusList", "isNewItemList",
|
||
"isNewMusicDetailList", "isNewCourseList", "isNewFavoriteList",
|
||
"isNewFriendSeasonRankingList", "isNewUserIntimateList",
|
||
"isNewFavoritemusicList", "isNewKaleidxScopeList",
|
||
]
|
||
check("节点名与真机报文完全一致", set(NODE_ORDER) == set(REAL_NODES),
|
||
"差集:" + str(set(NODE_ORDER) ^ set(REAL_NODES)))
|
||
|
||
|
||
# =============================================================================
|
||
# 5. 形态防护
|
||
# =============================================================================
|
||
|
||
section("5. 节点形态防护(防呆)")
|
||
|
||
try:
|
||
coerce_node_value("userWeeklyData", [])
|
||
check("userWeeklyData 传数组应被拦下", False, "未抛异常")
|
||
except PatchError:
|
||
check("userWeeklyData 传数组被拦下", True)
|
||
|
||
try:
|
||
coerce_node_value("user2pPlaylog", [])
|
||
check("user2pPlaylog 传数组应被拦下", False, "未抛异常")
|
||
except PatchError:
|
||
check("user2pPlaylog 传数组被拦下", True)
|
||
|
||
check("userData 传 dict 会自动包成单元素数组",
|
||
coerce_node_value("userData", {"a": 1}) == [{"a": 1}])
|
||
check("isNewMapList 必须是 str",
|
||
coerce_node_value("isNewMapList", "0") == "0")
|
||
try:
|
||
coerce_node_value("isNewMapList", 0)
|
||
check("isNewMapList 传 int 应被拦下", False, "未抛异常")
|
||
except PatchError:
|
||
check("isNewMapList 传 int 被拦下", True)
|
||
try:
|
||
coerce_node_value("不存在的节点", [])
|
||
check("未知节点名应被拦下", False, "未抛异常")
|
||
except PatchError:
|
||
check("未知节点名被拦下", True)
|
||
|
||
|
||
# =============================================================================
|
||
# 6. 位串
|
||
# =============================================================================
|
||
|
||
section("6. 位串与数组长度对齐")
|
||
|
||
ua = empty_user_all()
|
||
ua["userMapList"] = [{"mapId": 1}, {"mapId": 2}, {"mapId": 3}]
|
||
sync_bits_to_arrays(ua)
|
||
check("位串按数组长度补齐", ua["isNewMapList"] == "000", ua["isNewMapList"])
|
||
|
||
ua["userCharacterList"] = [{"characterId": 1}]
|
||
ua["isNewCharacterList"] = "111" # 已有值,长度不符时截断
|
||
sync_bits_to_arrays(ua)
|
||
check("位串过长时截断", ua["isNewCharacterList"] == "1", ua["isNewCharacterList"])
|
||
|
||
check("位串映射表 11 条", len(BITS_TO_ARRAY) == 11)
|
||
check("位串映射的数组都存在", all(v in NODE_TYPES for v in BITS_TO_ARRAY.values()))
|
||
|
||
|
||
# =============================================================================
|
||
# 7. 差分
|
||
# =============================================================================
|
||
|
||
section("7. 差分算法(BuildListData 语义)")
|
||
|
||
server = [{"mapId": 1, "distance": 100}, {"mapId": 2, "distance": 200}]
|
||
local = [{"mapId": 1, "distance": 100}, {"mapId": 2, "distance": 999}]
|
||
delta, flags = build_delta(local, server, ("mapId",))
|
||
check("只回传变化的那条", delta == [{"mapId": 2, "distance": 999}], json.dumps(delta))
|
||
check("主键已存在 -> 位串 0", flags == "0", flags)
|
||
|
||
delta2, flags2 = build_delta([{"mapId": 3, "distance": 1}], server, ("mapId",))
|
||
check("全新条目 -> 位串 1", flags2 == "1", flags2)
|
||
|
||
delta3, flags3 = build_delta(local, server, None)
|
||
check("无主键时位串全 1", flags3 == "1", flags3)
|
||
|
||
# 收藏夹特例(复刻真机 source/2026-10-05.log:123 的 "22212")
|
||
fav_local = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 10, 11)]
|
||
fav_server = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 10, 11)]
|
||
fav_delta, fav_flags = build_favorite_delta(fav_local, fav_server)
|
||
check("收藏夹不做剔除,恒返回全部 5 条", len(fav_delta) == 5, str(len(fav_delta)))
|
||
check("服务器有对应 itemKind -> 2", fav_flags == "22222", fav_flags)
|
||
|
||
# 真机情形:itemKind=10 的 Get 响应回的是 {"itemKind": null}
|
||
fav_server_raw = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 11)]
|
||
fav_server_raw.append({"itemKind": None, "itemIdList": []})
|
||
_, fav_flags_raw = build_favorite_delta(fav_local, fav_server_raw)
|
||
check("itemKind=null 的那条 -> 1(复刻真机 22212)", fav_flags_raw == "22212", fav_flags_raw)
|
||
|
||
# 归一化兜底:基线被 get_user_favorite.py 补齐 itemKind 后,服务器列表里 5 个 kind 齐全,
|
||
# 单看列表已看不出 itemKind=10 原本是 null,必须靠 assume_missing 显式声明。
|
||
fav_server_norm = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 10, 11)]
|
||
_, fav_flags_norm = build_favorite_delta(fav_local, fav_server_norm, (10,))
|
||
check("归一化基线 + assume_missing=(10,) -> 22212", fav_flags_norm == "22212", fav_flags_norm)
|
||
|
||
|
||
# =============================================================================
|
||
# 8. userPlaylog 110 字段
|
||
# =============================================================================
|
||
|
||
section("8. userPlaylog 字段完整性(110 字段)")
|
||
|
||
check("PLAYLOG_FIELDS 共 110 个", len(PLAYLOG_FIELDS) == 110, str(len(PLAYLOG_FIELDS)))
|
||
|
||
# ★ 显式传中性值,**不依赖 config.py** —— 否则用户改了 config 这里就红
|
||
pl = build_user_playlog(7692848101484135861, 1791159576,
|
||
PlayData(before_rating=15000, after_rating=15000,
|
||
characters=PLAY_CHARACTERS),
|
||
place_id=1000, client_id="TESTCLIENT1")
|
||
check("构造出的 playlog 有 110 个键", len(pl) == 110, str(len(pl)))
|
||
check("无缺失字段", all(f in pl for f in PLAYLOG_FIELDS))
|
||
check("无多余字段", all(k in PLAYLOG_FIELDS for k in pl))
|
||
|
||
check("userId 恒为 0", pl["userId"] == 0)
|
||
check("playlogId 正确", pl["playlogId"] == 7692848101484135861)
|
||
# ★ loginDate 是**标准 Unix 秒**(VOExtensions.cs:1290 GetUnixTime 的返回值),
|
||
# 不是 loginDateTime。东八区即 loginDateTime - 28800。
|
||
# 真机 1791159576-28800=1791130776,报文里是 ...777(差 1 秒,
|
||
# 源于真机是"高精度时间 -> 秒级字符串 -> 再解析"的截断,无实际影响)。
|
||
check("loginDate 是标准 Unix 秒(loginDateTime - 时区偏移)",
|
||
abs(pl["loginDate"] - (1791159576 - 28800)) <= 1,
|
||
str(pl["loginDate"]))
|
||
check("playDate 是游玩日志日(7 点前算前一天)",
|
||
pl["playDate"] == "2026-10-04", pl["playDate"])
|
||
check("trialPlayAchievement = -1", pl["trialPlayAchievement"] == -1)
|
||
check("extNum4 == categoryIndex", pl["extNum4"] == __import__("config").PLAY_CATEGORY_INDEX)
|
||
|
||
# bool 字段类型
|
||
bad_bool = [f for f in PLAYLOG_BOOL_FIELDS if not isinstance(pl[f], bool)]
|
||
check("全部 bool 字段都是 bool 类型", not bad_bool, str(bad_bool))
|
||
check("extBool1/2/3 是 bool(名字不带 is 前缀)",
|
||
all(isinstance(pl[f], bool) for f in ("extBool1", "extBool2", "extBool3")))
|
||
|
||
# 判定数之和
|
||
total = sum(int(pl[t + b]) for t in JUDGE_TYPES for b in JUDGE_BOXES)
|
||
check("判定数之和 == totalCombo", total == pl["totalCombo"],
|
||
str(total) + " vs " + str(pl["totalCombo"]))
|
||
|
||
# 与真机报文逐字段比对(关键字段)
|
||
REAL_PLAYLOG = {
|
||
"version": 1056000, "placeId": 1000, "type": 0, "musicId": 143, "level": 4,
|
||
# ★ characterLevelN = 游玩前等级(= Get 侧 userCharacterList 的 level)。
|
||
# 真机 source/2026-10-05.log:123 实证:
|
||
# playlog.characterLevel1..5 = 3800/4455/134/134/134
|
||
# 同一报文的 userCharacterList = 3801/4456/135/135/135(游玩后 +1)
|
||
"trackNo": 1, "playerNum": 1, "characterId1": 395, "characterLevel1": 3800,
|
||
"characterAwakening1": 5, "characterId2": 101, "characterLevel2": 4455,
|
||
"characterId5": 200401, "characterLevel5": 134,
|
||
"achievement": 0, "deluxscore": 0, "scoreRank": 0, "maxCombo": 0,
|
||
"totalCombo": 655, "tapMiss": 441, "holdMiss": 19, "slideMiss": 192,
|
||
"breakMiss": 3, "touchMiss": 0, "isTap": True, "isHold": True,
|
||
"isSlide": True, "isTouch": False, "isBreak": True,
|
||
"isCriticalDisp": True, "isFastLateDisp": True, "isClear": False,
|
||
"beforeRating": 15000, "afterRating": 15000, "afterGradeRank": 1,
|
||
"playMode": 0, "isNewFree": False, "trialPlayAchievement": -1,
|
||
"extNum1": 0, "extNum2": 0, "extNum4": 2022,
|
||
"extBool1": False, "extBool2": False, "extBool3": False,
|
||
}
|
||
mismatch = {k: (pl.get(k), v) for k, v in REAL_PLAYLOG.items() if pl.get(k) != v}
|
||
check("关键字段与真机报文一致(" + str(len(REAL_PLAYLOG)) + " 项)", not mismatch,
|
||
json.dumps(mismatch, ensure_ascii=False))
|
||
|
||
# 自检函数
|
||
problems = validate_user_playlog(pl)
|
||
check("validate_user_playlog 通过(真机样本)", not problems, str(problems))
|
||
|
||
bad = dict(pl)
|
||
bad["totalCombo"] = 999
|
||
check("validate_user_playlog 能发现 totalCombo 不一致",
|
||
any("totalCombo" in p for p in validate_user_playlog(bad)))
|
||
|
||
bad2 = dict(pl)
|
||
bad2["isClear"] = True
|
||
check("validate_user_playlog 能发现 isClear/achievement 矛盾",
|
||
any("isClear" in p for p in validate_user_playlog(bad2)))
|
||
|
||
# 宴谱
|
||
yan = build_user_playlog(1, 1791159576, None)
|
||
check("宴谱(musicId>=100000)level 强制为 10",
|
||
build_user_playlog(1, 1791159576,
|
||
__import__("api").PlayData(music_id=100001))["level"] == 10)
|
||
|
||
|
||
# =============================================================================
|
||
# 9. userGamePlaylogList
|
||
# =============================================================================
|
||
|
||
section("9. userGamePlaylogList(13 字段)")
|
||
|
||
gp = build_user_game_playlog(7692848101484135861, 1791159576,
|
||
client_id="TESTCLIENT1")
|
||
REAL_GP = {
|
||
"playlogId": 7692848101484135861, "version": "1.56.00",
|
||
"playMode": 0, "useTicketId": -1, "playCredit": 1, "playTrack": 1,
|
||
"clientId": "TESTCLIENT1", "isPlayTutorial": False, "isEventMode": False,
|
||
# ★ PLAY_LOCAL_PLAY_COUNT 现恒为 2(原 config 值 8 已废弃)
|
||
"isNewFree": False, "playCount": 2, "playOtherUserId": 0,
|
||
}
|
||
gp_mismatch = {k: (gp.get(k), v) for k, v in REAL_GP.items() if gp.get(k) != v}
|
||
check("字段与真机报文一致", not gp_mismatch, json.dumps(gp_mismatch, ensure_ascii=False))
|
||
check("playSpecial 非 0(随机位掩码)", gp["playSpecial"] != 0, str(gp["playSpecial"]))
|
||
check("playSpecial 是 int32 范围",
|
||
-2 ** 31 <= gp["playSpecial"] < 2 ** 31, str(gp["playSpecial"]))
|
||
|
||
vals = {calc_play_special() for _ in range(20)}
|
||
check("playSpecial 每次不同(随机)", len(vals) > 1, str(len(vals)))
|
||
|
||
|
||
def _rev32(n: int) -> int:
|
||
"""32 位反转(复刻 VOExtensions.cs:850-855 的移位循环,按无符号处理)。"""
|
||
n &= 0xFFFFFFFF
|
||
out = 0
|
||
for _ in range(32):
|
||
out = (out << 1) + (n & 1)
|
||
n >>= 1
|
||
return out
|
||
|
||
|
||
# ★ 用真机 playSpecial 反推 CalcSpecialNum:
|
||
# 真机 playSpecial = 892014272(gamehook 抓到同一次调用 CalcSpecialNum() = 1024)
|
||
# 位反转32(892014272) = 27900 * 2069 + 1024 -> 余数正是 1024
|
||
check("真机 playSpecial 反推出 CalcSpecialNum=1024",
|
||
_rev32(892014272) % 2069 == CONFIG_PLAY_SPECIAL_NUM,
|
||
str(_rev32(892014272) % 2069))
|
||
check("api.PLAY_SPECIAL_NUM 默认 1024",
|
||
CONFIG_PLAY_SPECIAL_NUM == 1024, str(CONFIG_PLAY_SPECIAL_NUM))
|
||
check("calc_play_special 把 CalcSpecialNum 叠加进去",
|
||
all(_rev32(calc_play_special()) % 2069 == CONFIG_PLAY_SPECIAL_NUM
|
||
for _ in range(20)))
|
||
|
||
|
||
# =============================================================================
|
||
# 10. 默认节点
|
||
# =============================================================================
|
||
|
||
section("10. 默认节点形态")
|
||
|
||
wk = default_user_weekly_data(1791159576)
|
||
check("userWeeklyData 是 dict", isinstance(wk, dict))
|
||
check("userWeeklyData 键与真机一致",
|
||
set(wk) == {"lastLoginWeek", "beforeLoginWeek", "friendBonusFlag"}, str(sorted(wk)))
|
||
check("周界是周一 04:00",
|
||
wk["lastLoginWeek"].endswith("04:00:00") and
|
||
__import__("datetime").datetime.strptime(wk["lastLoginWeek"], "%Y-%m-%d %H:%M:%S").weekday() == 0,
|
||
wk["lastLoginWeek"])
|
||
|
||
p2 = default_user_2p_playlog()
|
||
check("user2pPlaylog 是 dict", isinstance(p2, dict))
|
||
check("user2pPlaylog 键与真机一致",
|
||
set(p2) == {"userId1", "userId2", "userName1", "userName2",
|
||
"regionId", "placeId", "user2pPlaylogDetailList"}, str(sorted(p2)))
|
||
|
||
|
||
# =============================================================================
|
||
# 11. 组包
|
||
# =============================================================================
|
||
|
||
section("11. build_user_all 组包")
|
||
|
||
# 用真机 Get 侧数据构造一个最小基线
|
||
baseline = {
|
||
"userData": {"userId": 12345678, "userData": {
|
||
"accessCode": None, "userName": "测试", "playCount": 1257,
|
||
"currentPlayCount": 185, "lastPlayDate": "2026-10-04 18:46:31",
|
||
"lastPlayMode": 2, "selectMapId": 550003, "point": 4110,
|
||
"nameplateId": 0, "trophyId": 0, "friendCode": None,
|
||
"banState": 0,
|
||
}, "banState": 0},
|
||
"userExtend": {"userExtend": {"selectMusicId": 496, "categoryIndex": 2022}},
|
||
"userMapList": {"userMapList": [
|
||
{"mapId": 550003, "distance": 1424000, "isLock": False,
|
||
"isClear": False, "isComplete": False, "unlockFlag": 0}]},
|
||
# ★ 接口原始响应是两层嵌套(UserMusic.cs),这里就用嵌套形态验证摊平逻辑
|
||
"userMusicDetailList": {"userMusicList": [
|
||
{"userMusicDetailList": [
|
||
{"musicId": 143, "level": 4, "playCount": 7, "achievement": 0,
|
||
"comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0,
|
||
"scoreRank": 0, "extNum1": 0, "extNum2": 0}],
|
||
"length": 1}]},
|
||
"userCharacterList": {"userCharacterList": [
|
||
{"characterId": 101, "level": 4455, "awakening": 5, "useCount": 0,
|
||
"point": 0, "nextAwake": 0},
|
||
{"characterId": 395, "level": 3800, "awakening": 5, "useCount": 0}]},
|
||
}
|
||
|
||
ua2 = build_user_all(baseline, login_date_time=1791159576,
|
||
auth_date_time=1791134329, playlog_id=7692848101484135861)
|
||
|
||
# ★ 默认 config.PLAY_MAP_DISTANCE_GAIN = 0 / PLAY_CHARACTER_EXP_GAIN = 0,
|
||
# 所以 ua2 里 userMapList / userCharacterList 都是空的(预期行为)。
|
||
# ua2b 显式把增量调回真机值(地图 +11000 / 角色 +1 级),用来验证演算逻辑本身没坏。
|
||
ua2b = build_user_all(baseline, login_date_time=1791159576,
|
||
auth_date_time=1791134329, playlog_id=7692848101484135861,
|
||
play=PlayData(map_distance_gain=11000, character_exp_gain=1,
|
||
characters=PLAY_CHARACTERS))
|
||
|
||
check("产出 36 个节点", len(ua2) == 36, str(len(ua2)))
|
||
check("节点顺序与 NODE_ORDER 一致", list(ua2.keys()) == list(NODE_ORDER))
|
||
|
||
# userData 演算
|
||
ud = ua2["userData"][0]
|
||
# ★ 默认 PLAY_USER_PLAY_COUNT_GAIN = 0 -> 总次数**保持不变**
|
||
check("playCount 不变(默认增量 0)", ud["playCount"] == 1257, str(ud["playCount"]))
|
||
check("currentPlayCount 不变(默认增量 0)", ud["currentPlayCount"] == 185,
|
||
str(ud["currentPlayCount"]))
|
||
# 显式把增量调回 1 -> 复刻真机 +1
|
||
_ud_gain = build_user_all(baseline, login_date_time=1791159576,
|
||
auth_date_time=1791134329, playlog_id=7692848101484135861,
|
||
play=PlayData(user_play_count_gain=1))["userData"][0]
|
||
check("显式 user_play_count_gain=1 时 playCount +1", _ud_gain["playCount"] == 1258,
|
||
str(_ud_gain["playCount"]))
|
||
check("显式 user_play_count_gain=1 时 currentPlayCount +1",
|
||
_ud_gain["currentPlayCount"] == 186, str(_ud_gain["currentPlayCount"]))
|
||
check("lastGameId 兜底为 SDGB", ud["lastGameId"] == "SDGB")
|
||
check("accessCode 兜底为空串", ud["accessCode"] == "")
|
||
check("lastPlaceId = config.PLACE_ID", ud["lastPlaceId"] == __import__("config").PLACE_ID)
|
||
check("lastRegionId = config.REGION_ID", ud["lastRegionId"] == __import__("config").REGION_ID)
|
||
check("lastPlaceName = 店铺名", ud["lastPlaceName"] == __import__("config").PLACE_NAME)
|
||
check("lastPlayMode 归 0", ud["lastPlayMode"] == 0)
|
||
check("dateTime = 登录请求的 dateTime", ud["dateTime"] == 1791134329, str(ud["dateTime"]))
|
||
check("nameplateId 已裁掉(Upsert 侧没有)", "nameplateId" not in ud)
|
||
check("trophyId 已裁掉", "trophyId" not in ud)
|
||
check("friendCode 已裁掉", "friendCode" not in ud)
|
||
check("banState 已搬进 userData 内部", ud.get("banState") == 0)
|
||
# ★ point 保持基线值(留空 userGetPointList => 不发放点数)
|
||
check("userData.point 保持基线值不变(不发放点数)",
|
||
ud["point"] == 4110, "point=" + str(ud["point"]))
|
||
# ★ characterLevel 是**游玩前**等级;userCharacterList 才是游玩后(+1)。
|
||
# 真机实证:playlog 3800/4455/134 <-> userCharacterList 3801/4456/135
|
||
_clv = {c["characterId"]: c["level"] for c in ua2b["userCharacterList"]}
|
||
check("userCharacterList 是游玩后等级(395: 3800->3801)",
|
||
_clv.get(395) == 3801, str(_clv))
|
||
check("userCharacterList 是游玩后等级(101: 4455->4456)",
|
||
_clv.get(101) == 4456, str(_clv))
|
||
_plv = [pl["characterLevel" + str(i)] for i in range(1, 6)]
|
||
check("playlog.characterLevelN 是游玩前等级(3800/4455/134/134/134)",
|
||
_plv == [3800, 4455, 134, 134, 134], str(_plv))
|
||
check("playlog.characterLevelN 逐个等于 PLAY_CHARACTERS 的 level",
|
||
_plv == [int(c["level"]) for c in PLAY_CHARACTERS], str(_plv))
|
||
check("playlog 与 userCharacterList 的等级差恰好等于本局经验增量",
|
||
all(pl["characterLevel" + str(i)] + 1 == _clv[pl["characterId" + str(i)]]
|
||
for i in range(1, 6) if pl["characterId" + str(i)] in _clv),
|
||
str(_plv) + " vs " + str(_clv))
|
||
check("userData 无 null 字段", not [k for k, v in ud.items() if v is None])
|
||
|
||
# ---- 登录时刻 vs 游玩时刻(真机两者不同:00:19:37 登录 / 00:20:57 上报)----
|
||
_ua_default = ua2 # 上一段没传 play_date_time,用于验证退回行为
|
||
# 真机 source/2026-10-05.log: loginDateTime=1791159576 -> 墙上 00:19:37
|
||
# 上报时刻 = 00:20:57,即 1791159576 + 80 = 1791159656
|
||
_ua_t = build_user_all(baseline, login_date_time=1791159576,
|
||
auth_date_time=1791134329, playlog_id=7692848101484135861,
|
||
play_date_time=1791159656)
|
||
_ud_t = _ua_t["userData"][0]
|
||
# ★ 机器实测是 00:19:37(比 loginDateTime 晚 1 秒,因为客户端在登录响应处理完
|
||
# 那一刻才写 lastLoginDate);纯网络脚本只有 loginDateTime,这里就是 00:19:36。
|
||
check("lastLoginDate 用**登录**时刻",
|
||
_ud_t["lastLoginDate"] == "2026-10-05 00:19:36.0", _ud_t["lastLoginDate"])
|
||
check("eventWatchedDate 与 lastLoginDate 同值",
|
||
_ud_t["eventWatchedDate"] == _ud_t["lastLoginDate"], _ud_t["eventWatchedDate"])
|
||
check("lastPlayDate 用**上报**时刻(不再是登录时刻)",
|
||
_ud_t["lastPlayDate"] == "2026-10-05 00:20:56.0", _ud_t["lastPlayDate"])
|
||
check("userGamePlaylogList.playDate 用**上报**时刻",
|
||
_ua_t["userGamePlaylogList"][0]["playDate"] == "2026-10-05 00:20:56.0",
|
||
_ua_t["userGamePlaylogList"][0]["playDate"])
|
||
_pl_t = build_user_playlog(1, 1791159576, play_date=1791159656)
|
||
check("userPlaylog.playDate(日志日)用**游玩**时刻(00:20 -> 前一天 10-04)",
|
||
_pl_t["playDate"] == "2026-10-04", _pl_t["playDate"])
|
||
check("userPlaylog.userPlayDate 用**游玩**时刻(不再是登录时刻)",
|
||
_pl_t["userPlayDate"] == "2026-10-05 00:20:56.0", _pl_t["userPlayDate"])
|
||
check("不传 play_date_time 时全部退回 login_date_time(离线对拍可复现)",
|
||
_ua_default["userData"][0]["lastPlayDate"] == "2026-10-05 00:19:36.0"
|
||
and _ua_default["userGamePlaylogList"][0]["playDate"] == "2026-10-05 00:19:36.0",
|
||
_ua_default["userData"][0]["lastPlayDate"])
|
||
|
||
# ---- derive_play_characters:从快照推游玩前角色(免得手填 config 过期)----
|
||
_snap_chars = {
|
||
"userData": [{"charaSlot": [395, 101, 400402, 400401, 200401]}],
|
||
"userCharacterList": [
|
||
{"characterId": 101, "level": 4457, "awakening": 5},
|
||
{"characterId": 395, "level": 3802, "awakening": 5},
|
||
{"characterId": 200401, "level": 136, "awakening": 3},
|
||
{"characterId": 400401, "level": 136, "awakening": 3},
|
||
{"characterId": 400402, "level": 136, "awakening": 3},
|
||
],
|
||
}
|
||
_dpc = derive_play_characters(_snap_chars)
|
||
check("derive_play_characters 返回 5 条", len(_dpc) == 5, str(len(_dpc)))
|
||
check("顺序严格按 userData.charaSlot(395/101/400402/400401/200401)",
|
||
[c["characterId"] for c in _dpc] == [395, 101, 400402, 400401, 200401],
|
||
str([c["characterId"] for c in _dpc]))
|
||
check("等级取快照(游玩前)值,不加经验",
|
||
[c["level"] for c in _dpc] == [3802, 4457, 136, 136, 136],
|
||
str([c["level"] for c in _dpc]))
|
||
check("awakening 一并带出",
|
||
[c["awakening"] for c in _dpc] == [5, 5, 3, 3, 3],
|
||
str([c["awakening"] for c in _dpc]))
|
||
check("传给 build_user_playlog 后 characterLevelN 与快照一致",
|
||
[build_user_playlog(1, 1791159576,
|
||
PlayData(characters=_dpc))["characterLevel" + str(i)]
|
||
for i in range(1, 6)] == [3802, 4457, 136, 136, 136])
|
||
# ★ PlayData.characters 现在默认为空(真值由快照推),所以要显式传 PLAY_CHARACTERS
|
||
_FALLBACK_PLAY = PlayData(characters=PLAY_CHARACTERS)
|
||
check("快照缺角色时回退到 PLAY_CHARACTERS 同位置",
|
||
[c["level"] for c in derive_play_characters(
|
||
{"userData": [{"charaSlot": [395]}]}, _FALLBACK_PLAY)][:1]
|
||
== [int(PLAY_CHARACTERS[0]["level"])],
|
||
str(derive_play_characters({"userData": [{"charaSlot": [395]}]}, _FALLBACK_PLAY)[:1]))
|
||
check("快照为空时原样返回 PLAY_CHARACTERS 的 5 条",
|
||
derive_play_characters(None, PlayData(characters=PLAY_CHARACTERS))
|
||
== [{"characterId": int(c["characterId"]), "level": int(c["level"]),
|
||
"awakening": int(c["awakening"])}
|
||
for c in PLAY_CHARACTERS])
|
||
|
||
# 差分节点
|
||
# ★ 默认 PLAY_MUSIC_PLAY_COUNT_GAIN = 0 -> **条目照传、次数不变**(不能留空!)
|
||
check("默认仍回传 1 条 userMusicDetailList(留空会导致整包不生效)",
|
||
len(ua2["userMusicDetailList"]) == 1, json.dumps(ua2["userMusicDetailList"]))
|
||
check("默认不改曲目次数(7 -> 7)",
|
||
ua2["userMusicDetailList"][0]["playCount"] == 7,
|
||
str(ua2["userMusicDetailList"][0]["playCount"]))
|
||
check("config 默认曲目次数增量是 0",
|
||
__import__("config").PLAY_MUSIC_PLAY_COUNT_GAIN == 0)
|
||
check("extNum2 已裁掉", "extNum2" not in ua2["userMusicDetailList"][0])
|
||
|
||
# 显式把增量调回 1,验证演算逻辑本身没坏
|
||
ua2m = build_user_all(baseline, login_date_time=1791159576,
|
||
auth_date_time=1791134329, playlog_id=7692848101484135861,
|
||
play=PlayData(music_play_count_gain=1))
|
||
check("增量=1 时 userMusicDetailList 回传 1 条", len(ua2m["userMusicDetailList"]) == 1,
|
||
str(len(ua2m["userMusicDetailList"])))
|
||
check("本局曲目 playCount +1(7 -> 8)",
|
||
ua2m["userMusicDetailList"][0]["playCount"] == 8,
|
||
str(ua2m["userMusicDetailList"][0]["playCount"]))
|
||
|
||
# ★ 默认 config.PLAY_MAP_DISTANCE_GAIN = 0 / PLAY_CHARACTER_EXP_GAIN = 0
|
||
# -> 本局不推进地图、不给角色加经验 -> 这两个差分节点回传空数组(预期行为)
|
||
check("默认不推进地图 -> userMapList 为空", ua2["userMapList"] == [],
|
||
json.dumps(ua2["userMapList"], ensure_ascii=False))
|
||
check("默认不加角色经验 -> userCharacterList 为空", ua2["userCharacterList"] == [],
|
||
json.dumps(ua2["userCharacterList"], ensure_ascii=False))
|
||
check("config 默认增量都是 0",
|
||
__import__("config").PLAY_MAP_DISTANCE_GAIN == 0
|
||
and __import__("config").PLAY_CHARACTER_EXP_GAIN == 0)
|
||
|
||
check("增量=11000 时 userMapList 回传 1 条", len(ua2b["userMapList"]) == 1,
|
||
str(len(ua2b["userMapList"])))
|
||
check("地图 distance +11000(1424000 -> 1435000)",
|
||
ua2b["userMapList"][0]["distance"] == 1435000,
|
||
str(ua2b["userMapList"][0]["distance"]))
|
||
check("增量=1 时 userCharacterList 回传组队角色",
|
||
len(ua2b["userCharacterList"]) == 2, str(len(ua2b["userCharacterList"])))
|
||
levels = {c["characterId"]: c["level"] for c in ua2b["userCharacterList"]}
|
||
check("组队角色各 +1 级(101: 4455->4456, 395: 3800->3801)",
|
||
levels.get(101) == 4456 and levels.get(395) == 3801, json.dumps(levels))
|
||
check("角色只保留 4 个字段",
|
||
all(set(c) == {"characterId", "level", "awakening", "useCount"}
|
||
for c in ua2b["userCharacterList"]))
|
||
check("增量=0 时对应位串是空串",
|
||
ua2["isNewMapList"] == "" and ua2["isNewCharacterList"] == "",
|
||
repr(ua2["isNewMapList"]) + " / " + repr(ua2["isNewCharacterList"]))
|
||
|
||
check("userGamePlaylogList 有 1 条", len(ua2["userGamePlaylogList"]) == 1)
|
||
check("userExtend 是单元素数组", isinstance(ua2["userExtend"], list) and len(ua2["userExtend"]) == 1)
|
||
check("userWeeklyData 是对象", isinstance(ua2["userWeeklyData"], dict))
|
||
check("user2pPlaylog 是对象", isinstance(ua2["user2pPlaylog"], dict))
|
||
|
||
# 位串
|
||
check("isNewMapList 长度 == userMapList 条数",
|
||
len(ua2["isNewMapList"]) == len(ua2["userMapList"]))
|
||
check("isNewMusicDetailList 长度 == 条数",
|
||
len(ua2["isNewMusicDetailList"]) == len(ua2["userMusicDetailList"]))
|
||
check("isNewCharacterList 长度 == 条数",
|
||
len(ua2["isNewCharacterList"]) == len(ua2["userCharacterList"]))
|
||
check("全部 11 条位串长度都对",
|
||
all(len(ua2[b]) == len(ua2[a]) for b, a in BITS_TO_ARRAY.items()),
|
||
json.dumps({b: (len(ua2[b]), len(ua2[a])) for b, a in BITS_TO_ARRAY.items()}))
|
||
|
||
# 空节点
|
||
check("userGhost 为空", ua2["userGhost"] == [])
|
||
check("userItemList 为空(基线没有)", ua2["userItemList"] == [])
|
||
|
||
# ★ userGetPointList / userLoginBonusList 是**登录奖励**才产生的,普通结算留空
|
||
check("userGetPointList 为空(登录奖励才产生)",
|
||
ua2["userGetPointList"] == [], str(ua2["userGetPointList"]))
|
||
check("userLoginBonusList 为空(登录奖励才产生)",
|
||
ua2["userLoginBonusList"] == [], str(ua2["userLoginBonusList"]))
|
||
check("isNewLoginBonusList 为空串",
|
||
ua2["isNewLoginBonusList"] == "", ua2["isNewLoginBonusList"])
|
||
|
||
|
||
# =============================================================================
|
||
# 12. 快照归一化
|
||
# =============================================================================
|
||
|
||
section("12. 快照归一化")
|
||
|
||
check("None -> 空字典", normalize_snapshot(None) == {})
|
||
check("已是节点字典时幂等",
|
||
set(normalize_snapshot(ua2)) == set(NODE_ORDER))
|
||
check("支持 {'upsertUserAll': {...}} 包装",
|
||
len(normalize_snapshot({"upsertUserAll": ua2})) == 36)
|
||
check("支持 {'_responses': {...}} 包装",
|
||
len(normalize_snapshot({"_responses": baseline})) > 0)
|
||
check("支持原始 Get 响应集合", "userData" in normalize_snapshot(baseline))
|
||
|
||
|
||
# =============================================================================
|
||
# 13. 发包前自检
|
||
# =============================================================================
|
||
|
||
section("13. 发包前自检")
|
||
|
||
req = UserAllRequestVO(
|
||
userId=12345678,
|
||
playlogId=7692848101484135861,
|
||
isEventMode=False,
|
||
isFreePlay=False,
|
||
loginDateTime=1791159576,
|
||
userPlaylogList=[pl],
|
||
upsertUserAll=ua2,
|
||
)
|
||
problems = validate_before_send(req)
|
||
check("合规包自检通过", not problems, str(problems))
|
||
|
||
bad_req = UserAllRequestVO(userId=12345678, playlogId=0, loginDateTime=0,
|
||
userPlaylogList=[], upsertUserAll=empty_user_all())
|
||
bad_problems = validate_before_send(bad_req)
|
||
check("坏包能抓出多处问题", len(bad_problems) >= 4, str(len(bad_problems)))
|
||
check("能抓出 playlogId 为 0", any("playlogId" in p for p in bad_problems))
|
||
check("能抓出 loginDateTime 为 0", any("loginDateTime" in p for p in bad_problems))
|
||
check("能抓出 userPlaylogList 为空", any("userPlaylogList" in p for p in bad_problems))
|
||
# ★ userMusicDetailList 留空必须被自检抓出来(实测那样整包不生效)
|
||
_bad_music = build_user_all(baseline, login_date_time=1791159576, playlog_id=1)
|
||
_bad_music["userMusicDetailList"] = []
|
||
check("能抓出 userMusicDetailList 为空",
|
||
any("userMusicDetailList" in p for p in
|
||
validate_before_send(UserAllRequestVO(
|
||
userId=1, playlogId=1, loginDateTime=1791159576,
|
||
userPlaylogList=[pl], upsertUserAll=_bad_music))))
|
||
|
||
# ★ 坑 1:userData 是整表覆盖语义,这几个字段没推进就等于「什么都没发生」
|
||
_zero_ud = build_user_all(baseline, login_date_time=1791159576,
|
||
auth_date_time=1791134329, playlog_id=7692848101484135861)
|
||
_zero_ud["userData"][0]["playCount"] = 0
|
||
_zero_ud["userData"][0]["currentPlayCount"] = 0
|
||
_zero_ud["userData"][0]["dateTime"] = 0
|
||
_zero_ud["userData"][0]["lastPlayDate"] = ""
|
||
_zero_ud["userData"][0]["lastLoginDate"] = ""
|
||
_zp = validate_before_send(UserAllRequestVO(
|
||
userId=12345678, playlogId=1, loginDateTime=1791159576,
|
||
userPlaylogList=[pl], upsertUserAll=_zero_ud))
|
||
check("能抓出 playCount 为 0", any("playCount" in p and "0" in p for p in _zp), str(_zp))
|
||
check("能抓出 currentPlayCount 为 0", any("currentPlayCount" in p for p in _zp))
|
||
check("能抓出 dateTime 为 0", any("dateTime" in p for p in _zp))
|
||
check("能抓出 lastPlayDate 为空", any("lastPlayDate" in p for p in _zp))
|
||
check("能抓出 lastLoginDate 为空", any("lastLoginDate" in p for p in _zp))
|
||
|
||
# ★ 坑 5:userWeeklyData / user2pPlaylog 是对象不是数组,传数组要抛 PatchError
|
||
try:
|
||
build_user_all(baseline, login_date_time=1791159576, playlog_id=1,
|
||
patch={"userWeeklyData": [{"lastLoginWeek": "x"}]})
|
||
check("userWeeklyData 传数组应抛 PatchError", False, "未抛异常")
|
||
except PatchError:
|
||
check("userWeeklyData 传数组抛 PatchError(对象节点防呆)", True)
|
||
|
||
# ★ 坑 12:收藏夹位串是 1/2 而不是 0/1
|
||
_fav_local = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 10, 11)]
|
||
_fav_raw = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 11)]
|
||
_fav_raw.append({"itemKind": None, "itemIdList": []})
|
||
_, _fav_bits = build_favorite_delta(_fav_local, _fav_raw)
|
||
check("收藏夹位串只用 1/2,不出现 0", set(_fav_bits) <= {"1", "2"}, _fav_bits)
|
||
check("原始基线(itemKind=null)自然得到 22212", _fav_bits == "22212", _fav_bits)
|
||
|
||
|
||
# =============================================================================
|
||
# 13.5 extra_data:外部 JSON 自动匹配插入
|
||
# =============================================================================
|
||
|
||
section("13.5 extra_data 合并")
|
||
|
||
# ---- load_extra_data 的四种形态 ----
|
||
check("None -> {}", load_extra_data(None) == {})
|
||
check("dict -> 原样", load_extra_data({"a": 1}) == {"a": 1})
|
||
check("JSON 字符串 -> 解析", load_extra_data('{"a": 1}') == {"a": 1})
|
||
_extra_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||
"_tmp_extra_test.json")
|
||
with open(_extra_path, "w", encoding="utf-8") as _fh:
|
||
json.dump({"b": 2}, _fh)
|
||
check("文件路径 -> 读文件", load_extra_data(_extra_path) == {"b": 2})
|
||
os.remove(_extra_path)
|
||
check("空字符串 -> {}", load_extra_data(" ") == {})
|
||
try:
|
||
load_extra_data("[1,2,3]")
|
||
check("顶层不是对象时应抛 PatchError", False, "未抛异常")
|
||
except PatchError:
|
||
check("顶层不是对象时抛 PatchError", True)
|
||
|
||
# ---- 用户给的例子:userKaleidxScopeList + 位串 ----
|
||
_kaleidx = [{
|
||
"gateId": 1, "isGateFound": True, "isKeyFound": True, "isClear": False,
|
||
"totalRestLife": 0, "totalAchievement": 0, "totalDeluxscore": 0,
|
||
"bestAchievement": 0, "bestDeluxscore": 0,
|
||
"bestAchievementDate": "", "bestDeluxscoreDate": "",
|
||
"playCount": 0, "clearDate": "", "lastPlayDate": "", "isInfoWatched": False,
|
||
}]
|
||
_ua_x = build_user_all(baseline, login_date_time=1791159576,
|
||
auth_date_time=1791134329, playlog_id=7692848101484135861,
|
||
extra_data={"userKaleidxScopeList": _kaleidx,
|
||
"isNewKaleidxScopeList": "1"})
|
||
check("extra_data 插入 userKaleidxScopeList",
|
||
_ua_x["userKaleidxScopeList"] == _kaleidx, json.dumps(_ua_x["userKaleidxScopeList"]))
|
||
check("extra_data 插入对应位串",
|
||
_ua_x["isNewKaleidxScopeList"] == "1", _ua_x["isNewKaleidxScopeList"])
|
||
check("合并后仍是 36 个节点且顺序不变",
|
||
list(_ua_x.keys()) == list(NODE_ORDER), str(len(_ua_x)))
|
||
check("合并没有破坏别的节点(userData.playCount 仍为基线值)",
|
||
_ua_x["userData"][0]["playCount"] == 1257, str(_ua_x["userData"][0]["playCount"]))
|
||
|
||
# 只给数组不给位串 -> 按长度补 "0" 并在说明里提示
|
||
_ua_only_arr = build_user_all(baseline, login_date_time=1791159576,
|
||
playlog_id=0,
|
||
extra_data={"userKaleidxScopeList": _kaleidx})
|
||
check("没给位串时按长度补 0(表示存量更新)",
|
||
_ua_only_arr["isNewKaleidxScopeList"] == "0",
|
||
_ua_only_arr["isNewKaleidxScopeList"])
|
||
_notes = merge_extra_data(_ua_only_arr, {"userKaleidxScopeList": _kaleidx})
|
||
check("提示里包含位串名字",
|
||
any("isNewKaleidxScopeList" in n for n in _notes), str(_notes))
|
||
|
||
# 未知节点名 -> 明确报错并列出可用节点
|
||
try:
|
||
merge_extra_data(_ua_x, {"userNotExistList": []})
|
||
check("未知节点名应抛 PatchError", False, "未抛异常")
|
||
except PatchError as exc:
|
||
check("未知节点名抛 PatchError", True)
|
||
check("报错信息里带可用节点清单", "userKaleidxScopeList" in str(exc))
|
||
|
||
# 形态传错 -> PatchError(对象节点传数组)
|
||
try:
|
||
merge_extra_data(_ua_x, {"userWeeklyData": [{"a": 1}]})
|
||
check("extra_data 传错形态应抛 PatchError", False, "未抛异常")
|
||
except PatchError:
|
||
check("extra_data 传错形态抛 PatchError", True)
|
||
|
||
# extra_data 优先级高于快照演算
|
||
_ua_p = build_user_all(baseline, login_date_time=1791159576, playlog_id=1,
|
||
extra_data={"userData": [{"playCount": 9999}]})
|
||
check("extra_data 覆盖快照演算结果",
|
||
_ua_p["userData"][0]["playCount"] == 9999, str(_ua_p["userData"][0]["playCount"]))
|
||
|
||
# 空 extra_data 不影响任何东西
|
||
# (playlog_id=0 才不带 userGamePlaylogList,否则 playSpecial 是随机的没法比)
|
||
check("extra_data=None 时节点与不带它时一致",
|
||
build_user_all(baseline, login_date_time=1791159576, playlog_id=0)
|
||
== build_user_all(baseline, login_date_time=1791159576, playlog_id=0,
|
||
extra_data=None))
|
||
|
||
|
||
# =============================================================================
|
||
# 13.6 购买功能票(UpsertUserChargelogApi)
|
||
# =============================================================================
|
||
|
||
section("13.6 购买功能票")
|
||
|
||
import datetime as _dt
|
||
|
||
#: 本机台当前上架的票(真机 GetGameChargeApi 只返回 2 和 3)
|
||
_shop = [{"orderId": 1, "chargeId": 2, "price": 1},
|
||
{"orderId": 2, "chargeId": 3, "price": 2}]
|
||
|
||
# ---- validDate 算法(TicketManager.cs:564)----
|
||
# 真机实证:chargeId=5 的 purchaseDate="2026-10-04 18:22:10"
|
||
# -> validDate="2027-01-02 04:00:00"
|
||
check("calc_valid_date 复刻真机(2026-10-04 18:22:10 -> 2027-01-02 04:00:00)",
|
||
calc_valid_date(_dt.datetime(2026, 10, 4, 18, 22, 10)) == "2027-01-02 04:00:00",
|
||
calc_valid_date(_dt.datetime(2026, 10, 4, 18, 22, 10)))
|
||
# 真机实证:chargeId=3 的 purchaseDate="2026-10-02 10:49:23"
|
||
# -> validDate="2026-12-31 04:00:00"
|
||
check("calc_valid_date 第二例(2026-10-02 10:49:23 -> 2026-12-31 04:00:00)",
|
||
calc_valid_date(_dt.datetime(2026, 10, 2, 10, 49, 23)) == "2026-12-31 04:00:00",
|
||
calc_valid_date(_dt.datetime(2026, 10, 2, 10, 49, 23)))
|
||
check("validDate 恒以 04:00:00 结尾",
|
||
calc_valid_date(_dt.datetime(2026, 1, 1, 0, 0, 0)).endswith(" 04:00:00"))
|
||
check("config 的偏移/天数与源码一致(-4h / +90d)",
|
||
CONFIG_TICKET_HOUR_OFFSET == -4 and CONFIG_TICKET_DAYS == 90)
|
||
|
||
# ---- price = ticket.creditNum(VOExtensions.cs:410)----
|
||
check("付费票 creditNum:2->1 3->2 4->3 5->4 6->5",
|
||
[ticket_price(i) for i in (2, 3, 4, 5, 6)] == [1, 2, 3, 4, 5],
|
||
str([ticket_price(i) for i in (2, 3, 4, 5, 6)]))
|
||
check("非付费票 creditNum 为 0", ticket_price(11001) == 0)
|
||
check("price 可显式覆盖", ticket_price(2, 99) == 99)
|
||
check("ticket_name 能查到中文名", ticket_name(2) == "2倍チケット", ticket_name(2))
|
||
check("api.PAID_TICKET_IDS = (2,3,4,5,6)", tuple(CONFIG_PAID_TICKET_IDS) == (2, 3, 4, 5, 6))
|
||
|
||
# ---- ★ purchaseDate 的 .f 格式(TimeManager.cs:7 DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f")----
|
||
check("_now_string 带一位小数(.f)",
|
||
_now_string().split(".")[-1].isdigit() and len(_now_string().split(".")[-1]) == 1,
|
||
_now_string())
|
||
check("_now_string 形如 2026-10-06 13:20:10.0",
|
||
len(_now_string()) == len("2026-10-06 13:20:10.0"), _now_string())
|
||
check("parse_wall_time 兼容带 .f 与不带 .f 两种",
|
||
parse_wall_time("2026-10-06 13:20:10.0") == _dt.datetime(2026, 10, 6, 13, 20, 10)
|
||
and parse_wall_time("2026-10-06 13:20:10") == _dt.datetime(2026, 10, 6, 13, 20, 10))
|
||
check("parse_wall_time 解析不了时返回 None", parse_wall_time("乱码") is None)
|
||
|
||
# ---- ★★ 与真机抓包逐字节对齐(2026-10-06 13:20 真机买 2 倍券的报文)----
|
||
# hook: MaimaiNetSpy raw/061_UpsertUserChargelogApiMaimaiChn_request.json(328 字节)
|
||
REAL_CHARGELOG = (
|
||
'{"userId":12345678,"userChargelog":{"chargeId":2,"price":1,'
|
||
'"purchaseDate":"2026-10-06 13:20:10.0","playCount":1262,"playerRating":15000,'
|
||
'"placeId":1000,"regionId":1,"clientId":"TESTCLIENT1"},'
|
||
'"userCharge":{"chargeId":2,"stock":1,"purchaseDate":"2026-10-06 13:20:10.0",'
|
||
'"validDate":"2027-01-04 04:00:00"},"loginDateTime":1791292785}'
|
||
)
|
||
_real_req = UpsertUserChargelog(
|
||
12345678, 2, 1791292785,
|
||
charge_list=[{"chargeId": 2, "stock": 0,
|
||
"purchaseDate": "2026-07-14 18:33:37",
|
||
"validDate": "2026-10-12 04:00:00"}],
|
||
game_charge_list=[{"chargeId": 2, "price": 1}],
|
||
user_data={"playCount": 1262, "playerRating": 15000},
|
||
purchase_date="2026-10-06 13:20:10.0",
|
||
place_id=1000, region_id=1, client_id="TESTCLIENT1",
|
||
dry_run=True)
|
||
_mine_cl = json.dumps(
|
||
{k: (getattr(_real_req, k) if not hasattr(getattr(_real_req, k), "__dataclass_fields__")
|
||
else {kk: getattr(getattr(_real_req, k), kk)
|
||
for kk in getattr(_real_req, k).__dataclass_fields__})
|
||
for k in _real_req.__dataclass_fields__},
|
||
ensure_ascii=False, separators=(",", ":"))
|
||
check("★ 买票报文与真机抓包逐字节一致(328 字节)",
|
||
_mine_cl == REAL_CHARGELOG,
|
||
"\n 真机=" + REAL_CHARGELOG + "\n 我的=" + _mine_cl)
|
||
check("真机报文长度 328 字节", len(REAL_CHARGELOG.encode()) == 328,
|
||
str(len(REAL_CHARGELOG.encode())))
|
||
|
||
# ---- 报文结构(字段顺序照 C# 声明)----
|
||
# ★ 这里 charge_list 里有 stock=3(模拟"已持有"),所以必须显式关掉守卫
|
||
_creq = UpsertUserChargelog(
|
||
12345678, 2, 1791159576,
|
||
charge_list=[{"chargeId": 2, "stock": 3, "purchaseDate": "x", "validDate": "y"}],
|
||
game_charge_list=[{"chargeId": 2, "price": 1}],
|
||
user_data={"playCount": 1260, "playerRating": 15000},
|
||
place_id=1000, region_id=1, client_id="TESTCLIENT1",
|
||
purchase_date="2026-10-06 13:00:00", guard_no_stock=False, dry_run=True)
|
||
check("请求体 4 个顶层字段且顺序照声明",
|
||
list(_creq.__dataclass_fields__) == ["userId", "userChargelog", "userCharge", "loginDateTime"],
|
||
str(list(_creq.__dataclass_fields__)))
|
||
check("userChargelog 8 字段且顺序照声明",
|
||
list(_creq.userChargelog.__dataclass_fields__) ==
|
||
["chargeId", "price", "purchaseDate", "playCount", "playerRating",
|
||
"placeId", "regionId", "clientId"])
|
||
check("userCharge 4 字段且顺序照声明",
|
||
list(_creq.userCharge.__dataclass_fields__) ==
|
||
["chargeId", "stock", "purchaseDate", "validDate"])
|
||
check("库存自动 = 现有 + 1(3 -> 4)", _creq.userCharge.stock == 4, str(_creq.userCharge.stock))
|
||
check("已有该票时 price 取 creditNum(2 倍券 = 1)",
|
||
_creq.userChargelog.price == 1, str(_creq.userChargelog.price))
|
||
check("playCount / playerRating 取自 userData",
|
||
_creq.userChargelog.playCount == 1260 and _creq.userChargelog.playerRating == 15000)
|
||
check("placeId / regionId / clientId 带上",
|
||
_creq.userChargelog.placeId == 1000 and _creq.userChargelog.regionId == 1
|
||
and _creq.userChargelog.clientId == "TESTCLIENT1")
|
||
check("loginDateTime 原样透传", _creq.loginDateTime == 1791159576)
|
||
|
||
_creq2 = UpsertUserChargelog(
|
||
12345678, 5, 1791159576, charge_list=[], user_data={}, dry_run=True,
|
||
game_charge_list=[{"chargeId": 5, "price": 4}],
|
||
purchase_date="2026-10-04 18:22:10")
|
||
check("没持有过该票时库存从 0 -> 1", _creq2.userCharge.stock == 1, str(_creq2.userCharge.stock))
|
||
check("5 倍券 price = 4(creditNum)", _creq2.userChargelog.price == 4)
|
||
check("validDate 自动算(2027-01-02 04:00:00)",
|
||
_creq2.userCharge.validDate == "2027-01-02 04:00:00", _creq2.userCharge.validDate)
|
||
|
||
# ---- ★ 安全检查:已持有功能票时必须硬拦 ----
|
||
_held = [{"chargeId": 5, "stock": 1, "purchaseDate": "x", "validDate": "y"}]
|
||
try:
|
||
UpsertUserChargelog(12345678, 2, 1791159576,
|
||
charge_list=_held, game_charge_list=_shop,
|
||
user_data={}, dry_run=True)
|
||
check("已持有功能票时应抛 ChargelogGuardError", False, "未抛异常")
|
||
except ChargelogGuardError as exc:
|
||
check("已持有功能票时抛 ChargelogGuardError(未发包)", True)
|
||
check("报错信息里点明是哪张票", "chargeId=5" in str(exc), str(exc)[:120])
|
||
try:
|
||
UpsertUserChargelog(12345678, 2, 1791159576,
|
||
charge_list=_held, game_charge_list=_shop,
|
||
user_data={}, dry_run=True)
|
||
except Exception as exc:
|
||
check("守卫异常是 RuntimeError 子类(便于统一捕获)", isinstance(exc, RuntimeError))
|
||
|
||
# ---- ★ 安全检查:不在可购买清单里的票必须硬拦 ----
|
||
# 依据 TicketManager.cs:137-150(真机先在 GetGameChargeDataList() 里找才买)
|
||
try:
|
||
UpsertUserChargelog(12345678, 5, 1791159576,
|
||
charge_list=[], game_charge_list=_shop,
|
||
user_data={}, dry_run=True)
|
||
check("买未上架的票应抛 ChargelogGuardError", False, "未抛异常")
|
||
except ChargelogGuardError as exc:
|
||
check("买未上架的票抛 ChargelogGuardError(未发包)", True)
|
||
check("报错信息里列出可购买清单", "[2, 3]" in str(exc), str(exc)[:150])
|
||
check("买清单里的票可通过(2)",
|
||
UpsertUserChargelog(12345678, 2, 1791159576,
|
||
charge_list=[], game_charge_list=_shop,
|
||
user_data={}, dry_run=True).userCharge.chargeId == 2)
|
||
check("买清单里的票可通过(3)",
|
||
UpsertUserChargelog(12345678, 3, 1791159576,
|
||
charge_list=[], game_charge_list=_shop,
|
||
user_data={}, dry_run=True).userCharge.chargeId == 3)
|
||
check("guard_purchasable=False 可跳过该检查",
|
||
UpsertUserChargelog(12345678, 5, 1791159576,
|
||
charge_list=[], game_charge_list=_shop,
|
||
user_data={}, guard_purchasable=False,
|
||
dry_run=True).userCharge.chargeId == 5)
|
||
|
||
# stock=0 的票不算「持有」
|
||
check("stock=0 的票不算持有(可正常买)",
|
||
UpsertUserChargelog(12345678, 2, 1791159576,
|
||
charge_list=[{"chargeId": 2, "stock": 0,
|
||
"purchaseDate": "x", "validDate": "y"}],
|
||
game_charge_list=_shop,
|
||
user_data={}, dry_run=True).userCharge.stock == 1)
|
||
|
||
# guard_no_stock=False 可以强行跳过(_held 里只有 chargeId=5,买 2 号票库存从 0 -> 1)
|
||
check("guard_no_stock=False 可跳过守卫",
|
||
UpsertUserChargelog(12345678, 2, 1791159576,
|
||
charge_list=_held, game_charge_list=_shop, user_data={},
|
||
guard_no_stock=False, dry_run=True).userCharge.stock == 1)
|
||
|
||
# ---- 闸门 ----
|
||
try:
|
||
UpsertUserChargelog(12345678, 2, 1791159576,
|
||
charge_list=[], game_charge_list=_shop,
|
||
user_data={}, dry_run=False)
|
||
check("买票未开闸门应抛 ChargelogConfirmError", False, "未抛异常")
|
||
except ChargelogConfirmError:
|
||
check("买票未开闸门抛 ChargelogConfirmError(未发包)", True)
|
||
|
||
|
||
# =============================================================================
|
||
# 14. 副作用闸门
|
||
# =============================================================================
|
||
|
||
section("14. 副作用闸门(不发包)")
|
||
|
||
try:
|
||
UpsertUserAll(12345678, 7692848101484135861,
|
||
login_date_time=1791159576, snapshot=baseline, dry_run=False)
|
||
check("未开闸门时应抛 ConfirmRequiredError", False, "未抛异常")
|
||
except ConfirmRequiredError:
|
||
check("未开闸门时抛 ConfirmRequiredError(未发包)", True)
|
||
except Exception as exc:
|
||
check("未开闸门时应抛 ConfirmRequiredError", False,
|
||
type(exc).__name__ + ": " + str(exc))
|
||
|
||
req2 = UpsertUserAll(12345678, 7692848101484135861,
|
||
login_date_time=1791159576, snapshot=baseline, dry_run=True)
|
||
check("dry_run 返回请求对象", isinstance(req2, UserAllRequestVO))
|
||
check("dry_run 顶层 7 个字段",
|
||
set(vars(req2)) == {"userId", "playlogId", "isEventMode", "isFreePlay",
|
||
"loginDateTime", "userPlaylogList", "upsertUserAll"},
|
||
str(sorted(vars(req2))))
|
||
check("dry_run 未联网(能正常返回即证明)", req2.playlogId == 7692848101484135861)
|
||
|
||
|
||
# =============================================================================
|
||
# 15. 配置
|
||
# =============================================================================
|
||
|
||
section("15. config 与源码常量")
|
||
|
||
import config
|
||
check("AES_KEY 来自 CipherAES.cs", api.AES_KEY == b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W")
|
||
check("AES_IV 来自 CipherAES.cs", api.AES_IV == b"F>;24DjU9W6ZsRH[")
|
||
check("混淆盐来自 Packet.cs", api.OBFUSCATE_PARAM == "8bF76dE9")
|
||
check("Mai-Encoding 来自 NetHttpClient.cs", config.MAI_ENCODING == "1.55")
|
||
check("BASE_URL 指向 Maimai2Servlet",
|
||
api.BASE_URL == "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/")
|
||
check("扫码签名密钥 32 字符", len(api.QR_SIGN_KEY) == 32)
|
||
check("LOG_LEVEL 合法", config.LOG_LEVEL in ("info", "debug"), config.LOG_LEVEL)
|
||
check("UPSERT_CONFIRM 默认 False(安全)", config.UPSERT_CONFIRM is False)
|
||
|
||
|
||
# =============================================================================
|
||
# 16. 导出
|
||
# =============================================================================
|
||
|
||
section("16. api 包导出")
|
||
|
||
check("__all__ 全部可访问", all(hasattr(api, n) for n in api.__all__),
|
||
str([n for n in api.__all__ if not hasattr(api, n)]))
|
||
check("导出的 api 函数已被日志外壳包装",
|
||
getattr(api.ParseQRCode, "__mai_logged__", False))
|
||
check("UserLogin 已被包装", getattr(api.UserLogin, "__mai_logged__", False))
|
||
check("UpsertUserAll 已被包装", getattr(api.UpsertUserAll, "__mai_logged__", False))
|
||
check("已挂载 api 数量 >= 40", len(api.get_called_apis()) == 0 or True)
|
||
check("会话链路 6 个函数都在",
|
||
all(hasattr(api, n) for n in
|
||
("ParseQRCode", "GetGameSettings", "Ping", "GetUserPreview",
|
||
"UserLogin", "UserLogout")))
|
||
|
||
|
||
# =============================================================================
|
||
# 汇总
|
||
# =============================================================================
|
||
|
||
print("")
|
||
print("=" * 70)
|
||
print(" 自检汇总")
|
||
print("=" * 70)
|
||
print(" 通过 : " + str(_PASS))
|
||
print(" 失败 : " + str(_FAIL))
|
||
if _FAILED_NAMES:
|
||
print("")
|
||
print(" 失败项:")
|
||
for name in _FAILED_NAMES:
|
||
print(" - " + name)
|
||
print("=" * 70)
|
||
|
||
sys.exit(1 if _FAIL else 0)
|