diff --git a/api/upsert_user_all.py b/api/upsert_user_all.py index 34ccc16..65857e4 100644 --- a/api/upsert_user_all.py +++ b/api/upsert_user_all.py @@ -57,6 +57,13 @@ source/2026-10-05.log:123 逐节点核对: 写入后「看起来什么都没发生」,这就是「returnCode=1 但没落库」的根因。 差分节点若为空,服务端也看不出本局有任何变化。 + ⚠ 本工程的默认值与上表(真机)**有意不同**: + config.PLAY_USER_PLAY_COUNT_GAIN = 0(默认) + -> userData.playCount / currentPlayCount **保持不变**,不复刻真机的 +1。 + 想复刻真机就把它改成 1。 + 其它增量同理:PLAY_MUSIC_PLAY_COUNT_GAIN / PLAY_MAP_DISTANCE_GAIN / + PLAY_CHARACTER_EXP_GAIN 默认都是 0。 + 所以本模块的流程是: 1. 从 snapshot(登录后抓的 Get 响应)建立**基线** @@ -687,7 +694,11 @@ class PlayData: local_play_count: int = field(default_factory=lambda: config.PLAY_LOCAL_PLAY_COUNT) # 演算增量 - #: 曲目历史游玩次数增量(0 = 不回传 userMusicDetailList) + #: 用户总游玩次数增量(0 = userData.playCount / currentPlayCount 都不变) + #: ★ 默认 0:不再自动 +1。想复刻真机就设成 1。 + user_play_count_gain: int = field( + default_factory=lambda: getattr(config, "PLAY_USER_PLAY_COUNT_GAIN", 0)) + #: 曲目历史游玩次数增量(0 = 条目照传但次数不变) music_play_count_gain: int = field( default_factory=lambda: config.PLAY_MUSIC_PLAY_COUNT_GAIN) map_distance_gain: int = field(default_factory=lambda: config.PLAY_MAP_DISTANCE_GAIN) @@ -1504,8 +1515,9 @@ def apply_play_effects( ★ 这是本模块最关键的一步。真机对照(source/2026-10-05.log): - userData.playCount 1257 -> 1258 (+1) - userData.currentPlayCount 185 -> 186 (+1) + userData.playCount 1257 -> 1258 (+1) ← 真机 + userData.currentPlayCount 185 -> 186 (+1) ← 真机 + ★ 本工程默认 PLAY_USER_PLAY_COUNT_GAIN = 0,这两个**不变**。 userData.lastPlayDate 旧 -> 本局时刻 userData.lastPlayMode 2 -> 0 userData.lastLoginDate 旧 -> 本次登录时刻 @@ -1550,8 +1562,11 @@ def apply_play_effects( ud["lastRegionId"] = region_id ud["lastClientId"] = client_id ud["lastCountryCode"] = ud.get("lastCountryCode") or "CHN" - ud["playCount"] = int(ud.get("playCount", 0) or 0) + 1 - ud["currentPlayCount"] = int(ud.get("currentPlayCount", 0) or 0) + 1 + # ★ 默认增量 0:playCount / currentPlayCount 保持不变 + # (config.PLAY_USER_PLAY_COUNT_GAIN,想复刻真机 +1 就改成 1) + _pc_gain = int(getattr(play, "user_play_count_gain", 0) or 0) + ud["playCount"] = int(ud.get("playCount", 0) or 0) + _pc_gain + ud["currentPlayCount"] = int(ud.get("currentPlayCount", 0) or 0) + _pc_gain ud["lastPlayDate"] = wall # 本局上报时刻 ud["lastPlayMode"] = play.play_mode ud["lastLoginDate"] = login_wall # 本次登录时刻 @@ -1585,9 +1600,10 @@ def apply_play_effects( # ---------------- userMusicDetailList(差分:本局那条曲目)---------------- # ★★ 这个节点**必须回传本局那条**,不能留空 —— 实测证明: # 同一份代码、同一账号,唯一差别是这里留空 vs 回传 1 条: - # 回传 1 条 -> returnCode=1 且 userData.playCount 真的 +1(落库) - # 留空 -> returnCode=1 但 playCount 纹丝不动(**没落库**) + # 回传 1 条 -> returnCode=1 且服务端确实写入(当时靠 playCount +1 观察到) + # 留空 -> returnCode=1 但什么都没变(**没落库**) # 即「本局有 playlog 却没有任何曲目明细」会被服务端当成无效包静默丢弃。 + # ★ 现在 playCount 默认不再 +1,落库判据改看 lastPlayDate(见 verify_upsert_landing)。 # # ★ 但 playCount 的**增量**可以自由控制: # config.PLAY_MUSIC_PLAY_COUNT_GAIN = 0(默认)-> 条目照传,次数不变 @@ -1794,8 +1810,8 @@ def build_user_all( # PLAY_MUSIC_PLAY_COUNT_GAIN = 0(本局不改次数)且成绩也没刷新时, # 本局那条会被剔光 -> 节点变空。 # ★ 实测(同一份代码、同一账号,唯一差别就是这个节点): - # 回传 1 条 -> returnCode=1 且 userData.playCount 真的 +1(落库) - # 留空 -> returnCode=1 但 playCount 纹丝不动(**整包没生效**) + # 回传 1 条 -> returnCode=1 且服务端确实写入 + # 留空 -> returnCode=1 但什么都没变(**整包没生效**) # 所以这里兜底:只要本局确实打了一首歌,就一定回传它那一条。 # 位串给 "0" = 服务器侧已有该 (musicId, level),属于更新而非新增。 if not (user_all.get("userMusicDetailList") or []): @@ -2239,12 +2255,29 @@ def verify_upsert_landing( except Exception as exc: log_warning(" 曲目明细回查失败:" + str(exc)) - ok = (int(after_ud.get("playCount") or 0) > int(before_ud.get("playCount") or 0)) - if ok: - log_info("【落库成功】userData.playCount 已 +1") + # ★ 判定依据随增量走: + # 增量 > 0 -> playCount 应当变大(原来的判据) + # 增量 = 0 -> playCount 本来就不该变,改用 lastPlayDate 是否更新来判断 + _pc_gain = int(getattr(p, "user_play_count_gain", 0) or 0) + if _pc_gain: + ok = (int(after_ud.get("playCount") or 0) + > int(before_ud.get("playCount") or 0)) + detail = "userData.playCount 已 +" + str(_pc_gain) else: - log_error("【疑似未落库】userData.playCount 没有变化," + _b_lpd = str(before_ud.get("lastPlayDate") or "") + _a_lpd = str(after_ud.get("lastPlayDate") or "") + ok = bool(_a_lpd) and _a_lpd != _b_lpd + detail = "userData.lastPlayDate " + _b_lpd + " -> " + _a_lpd + + if ok: + log_info("【落库成功】" + detail) + else: + log_error("【疑似未落库】" + detail + "," "请对照 docs/UpsertUserAll踩坑清单.md 排查") + if not _pc_gain: + log_warning(" 提示:PLAY_USER_PLAY_COUNT_GAIN=0 时 playCount 不变属正常," + "此处看的是 lastPlayDate;若刚上传的就是同一局," + "lastPlayDate 本来就可能相等(假阴性)") return ok diff --git a/buy_ticket.py b/buy_ticket.py index 398d99e..c1e786e 100644 --- a/buy_ticket.py +++ b/buy_ticket.py @@ -98,7 +98,7 @@ def show_charges(user_id, label): def parse_args(argv): opts = {"qr": "", "charge_id": 5, "send": False, "list": False, - "debug": False, "skip_entry": False, "wait_before": 5.0} + "debug": False, "skip_entry": False, "wait_before": 30.0} i = 0 while i < len(argv): a = argv[i] diff --git a/config.py.example b/config.py.example index c4f42e6..631cde8 100644 --- a/config.py.example +++ b/config.py.example @@ -314,6 +314,18 @@ PLAY_LOCAL_PLAY_COUNT = 8 # 改成 0 不会报错,但 playSpecial 的位模式就不是真机那种了。 PLAY_SPECIAL_NUM = 1024 +# --- 用户总游玩次数增量 --- +# +# ★ 默认 0 = **userData.playCount / currentPlayCount 都不变**(本局不计入总次数)。 +# 想复刻真机的「+1」就改成 1。 +# +# 为什么默认改成 0: +# playCount 是账号的**总游玩次数**,是玩家可见的统计值; +# 本工程做「成绩上传」时不需要改动它,保持与上传前一致更安全。 +# ★ 改成 0 之后的副作用:「落库回查」不能再靠 playCount 是否 +1 判断, +# 会自动改用 lastPlayDate 是否更新来判断(见 verify_upsert_landing)。 +PLAY_USER_PLAY_COUNT_GAIN = 0 + # --- 曲目历史游玩次数增量 --- # # ★ 默认 0 = 本局**不改动**该曲目的历史游玩次数。 @@ -439,6 +451,7 @@ __all__ = [ "PLAY_CHARACTERS", "PLAY_CREDIT", "PLAY_USE_TICKET_ID", "PLAY_MODE", "PLAY_IS_TUTORIAL", "PLAY_IS_EVENT_MODE", "PLAY_IS_NEW_FREE", "PLAY_IS_FREEDOM_MODE", "PLAY_LOCAL_PLAY_COUNT", "PLAY_SPECIAL_NUM", + "PLAY_USER_PLAY_COUNT_GAIN", "PLAY_MUSIC_PLAY_COUNT_GAIN", "PLAY_MAP_DISTANCE_GAIN", "PLAY_CHARACTER_EXP_GAIN", "PLAY_SHOP_ITEM_IDS", # 闸门 diff --git a/gamehook/Main.cs b/gamehook/Main.cs index cf2ec65..c90b2c0 100644 --- a/gamehook/Main.cs +++ b/gamehook/Main.cs @@ -87,7 +87,7 @@ namespace MaimaiNetSpy nameof(PreAddCookie), null); n += PatchBySig(h, "Net.NetHttpClient", "Request", new[] { typeof(byte[]), typeof(string), typeof(string) }, - nameof(PreRequest), null); + nameof(PreRequest), nameof(PostRequestDumpHeaders)); n += PatchBySig(h, "Net.NetHttpClient", "GetResponse", Type.EmptyTypes, null, nameof(PostGetResponse)); @@ -265,13 +265,85 @@ namespace MaimaiNetSpy // 状态清理交给下一次 PreRequest } + // ------------------------------------------------------------------ + // ★ 真实 HTTP 请求头 dump + // ------------------------------------------------------------------ + // + // 为什么必须用 postfix: + // PreRequest 跑在 Request() 方法体**之前**,那时 + // Method / ContentType / UserAgent / Content-Encoding 都还没设置。 + // 只有 postfix 才能看到最终真正要发出去的头(含框架自带的)。 + // + // 目的:把真机的**完整请求头**抓下来,和纯 Python 脚本发的做逐字节对比。 + // (排查「报文一致但服务端 returnCode=0」这类问题) + private static void PostRequestDumpHeaders(Net.NetHttpClient __instance) + { + try + { + var fld = AccessTools.Field(typeof(Net.NetHttpClient), "_request"); + var wr = (fld == null ? null : fld.GetValue(__instance)) as System.Net.HttpWebRequest; + if (wr == null) { Line("| [头 dump] _request 取不到"); return; } + + var sb = new StringBuilder(); + sb.AppendLine("| ---- ★ 真实 HTTP 请求头(HttpWebRequest,含框架自带)----"); + sb.AppendLine("| [Method] : " + wr.Method); + sb.AppendLine("| [ContentType] : " + wr.ContentType); + sb.AppendLine("| [UserAgent] : " + wr.UserAgent); + sb.AppendLine("| [ContentLength] : " + wr.ContentLength); + sb.AppendLine("| [KeepAlive] : " + wr.KeepAlive); + sb.AppendLine("| [Expect] : " + wr.Expect); + sb.AppendLine("| [AutomaticDecompression] : " + wr.AutomaticDecompression); + sb.AppendLine("| [AllowAutoRedirect] : " + wr.AllowAutoRedirect); + try + { + foreach (string k in wr.Headers.AllKeys) + sb.AppendLine("| " + k + ": " + wr.Headers[k]); + } + catch (Exception e2) { sb.AppendLine("| [Headers 枚举失败] " + e2.Message); } + try + { + sb.AppendLine("| [Cookie 头] : " + + (wr.CookieContainer == null ? "(null container)" + : "[" + wr.CookieContainer.GetCookieHeader(wr.RequestUri) + "]")); + } + catch (Exception e3) { sb.AppendLine("| [Cookie 取失败] " + e3.Message); } + Line(sb.ToString()); + } + catch (Exception e) + { + Line("| [头 dump 失败] " + e); + } + } + // ------------------------------------------------------------------ // GetResponse —— 解密后的响应 // ------------------------------------------------------------------ - private static void PostGetResponse(ref MemoryStream __result) + private static void PostGetResponse(Net.NetHttpClient __instance, ref MemoryStream __result) { _respCount++; + + // ★ 响应头 dump(尤其看 UserLoginApi 的 Set-Cookie: JSESSIONID) + try + { + var fldR = AccessTools.Field(typeof(Net.NetHttpClient), "_response"); + var resp = (fldR == null ? null : fldR.GetValue(__instance)) as System.Net.HttpWebResponse; + if (resp != null) + { + var sh = new StringBuilder(); + sh.AppendLine("| ---- ★ 真实 HTTP 响应头 ----"); + sh.AppendLine("| [StatusCode] : " + (int)resp.StatusCode); + try + { + foreach (string k in resp.Headers.AllKeys) + sh.AppendLine("| " + k + ": " + resp.Headers[k]); + } + catch (Exception e4) { sh.AppendLine("| [响应头枚举失败] " + e4.Message); } + Line(sh.ToString()); + } + } + catch (Exception e5) { Line("| [响应头 dump 失败] " + e5.Message); } + try { if (__result == null) return; diff --git a/selftest.py b/selftest.py index db786f9..8bdca71 100644 --- a/selftest.py +++ b/selftest.py @@ -542,8 +542,18 @@ check("节点顺序与 NODE_ORDER 一致", list(ua2.keys()) == list(NODE_ORDER)) # userData 演算 ud = ua2["userData"][0] -check("playCount +1", ud["playCount"] == 1258, str(ud["playCount"])) -check("currentPlayCount +1", ud["currentPlayCount"] == 186, str(ud["currentPlayCount"])) +# ★ 默认 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) @@ -843,8 +853,8 @@ 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 仍 +1)", - _ua_x["userData"][0]["playCount"] == 1258, str(_ua_x["userData"][0]["playCount"])) +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,