From 88a2b319f8ff178a324b017384d496ff9c73c55d Mon Sep 17 00:00:00 2001 From: mars <3361409208@qq.com> Date: Tue, 4 Aug 2026 15:09:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(tng):=20=E7=BA=A2=E5=8C=85=E9=A2=86?= =?UTF-8?q?=E5=8F=96=E5=8F=B0=E5=8F=8C=E9=80=9A=E9=81=93=E6=8E=A8=E9=80=81?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A4=8D=E9=87=91=E9=A2=9D/?= =?UTF-8?q?=E6=8C=89=E5=8C=85=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook Quake RPC 抓 Money Packet;解析 Money 对象金额;调试台按单个红包展示排行;支持 USB 本机与共享网段双地址转发。 --- .../miraclegarden/smsmessage/AppConfig.java | 11 +- .../smsmessage/network/DebugForwarder.java | 82 ++-- debug-server/server.py | 426 +++++++++++++++++- .../xposed/hook/TngMoneyPacketHook.java | 320 ++++++++++--- 4 files changed, 751 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/com/miraclegarden/smsmessage/AppConfig.java b/app/src/main/java/com/miraclegarden/smsmessage/AppConfig.java index 81b4311..630de4b 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/AppConfig.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/AppConfig.java @@ -12,11 +12,18 @@ public final class AppConfig { public static final boolean ENABLE_DEBUG_FORWARD = true; /** - * 调试服务地址。 + * 调试服务地址(可同时配置 USB 本机 + 局域网)。 * USB + adb reverse: http://127.0.0.1:8765 * Wi-Fi: http://<电脑局域网IP>:8765 + * 会向列表中每个地址各推送一次;不可达的静默失败。 */ - public static final String DEBUG_SERVER_URL = "http://127.0.0.1:8765"; + public static final String[] DEBUG_SERVER_URLS = { + "http://127.0.0.1:8765", + "http://10.151.104.25:8765", + }; + + /** 兼容旧引用:取第一个调试地址 */ + public static final String DEBUG_SERVER_URL = DEBUG_SERVER_URLS[0]; /** 是否定期唤醒监听列表中的 App(保持进程 / Hook 加载) */ public static final boolean ENABLE_MONITORED_APP_KEEP_ALIVE = true; diff --git a/app/src/main/java/com/miraclegarden/smsmessage/network/DebugForwarder.java b/app/src/main/java/com/miraclegarden/smsmessage/network/DebugForwarder.java index 37af8a6..d5ce864 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/network/DebugForwarder.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/network/DebugForwarder.java @@ -10,6 +10,7 @@ import com.miraclegarden.smsmessage.MessageInfo; import org.json.JSONObject; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import okhttp3.Call; import okhttp3.Callback; @@ -21,6 +22,7 @@ import okhttp3.Response; /** * 将抓取到的消息转发到 PC 本地调试服务,便于浏览器查看。 + * 支持同时向本机(USB/adb reverse)和局域网地址推送。 */ public final class DebugForwarder { @@ -51,6 +53,14 @@ public final class DebugForwarder { return; } + String[] urls = AppConfig.DEBUG_SERVER_URLS; + if (urls == null || urls.length == 0) { + if (onComplete != null) { + onComplete.run(); + } + return; + } + try { JSONObject json = new JSONObject(); json.put("source", source != null ? source : "unknown"); @@ -60,39 +70,49 @@ public final class DebugForwarder { json.put("group", resolveGroup(title, messageInfo)); json.put("content", content != null ? content : ""); json.put("timestamp", timestamp); + final String body = json.toString(); + final AtomicInteger pending = new AtomicInteger(urls.length); - Request request = new Request.Builder() - .url(AppConfig.DEBUG_SERVER_URL + "/api/debug/push") - .header("Content-Type", "application/json") - .post(RequestBody.create(json.toString(), JSON)) - .build(); - - Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName() - + " -> " + AppConfig.DEBUG_SERVER_URL); - - CLIENT.newCall(request).enqueue(new Callback() { - @Override - public void onFailure(Call call, java.io.IOException e) { - Log.w(TAG, "forward failed [" + source + "]: " + e.getMessage()); - if (onComplete != null) { - onComplete.run(); - } + for (String baseUrl : urls) { + if (TextUtils.isEmpty(baseUrl)) { + finishOne(pending, onComplete); + continue; } + final String target = baseUrl.endsWith("/") + ? baseUrl.substring(0, baseUrl.length() - 1) + : baseUrl; + Request request = new Request.Builder() + .url(target + "/api/debug/push") + .header("Content-Type", "application/json") + .post(RequestBody.create(body, JSON)) + .build(); - @Override - public void onResponse(Call call, Response response) { - int code = response.code(); - response.close(); - if (code >= 200 && code < 300) { - Log.d(TAG, "forward ok [" + source + "] " + title); - } else { - Log.w(TAG, "forward http " + code + " [" + source + "] " + title); + Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName() + + " -> " + target); + + CLIENT.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, java.io.IOException e) { + Log.w(TAG, "forward failed [" + source + "] -> " + target + + ": " + e.getMessage()); + finishOne(pending, onComplete); } - if (onComplete != null) { - onComplete.run(); + + @Override + public void onResponse(Call call, Response response) { + int code = response.code(); + response.close(); + if (code >= 200 && code < 300) { + Log.d(TAG, "forward ok [" + source + "] -> " + target + + " " + title); + } else { + Log.w(TAG, "forward http " + code + " [" + source + "] -> " + + target + " " + title); + } + finishOne(pending, onComplete); } - } - }); + }); + } } catch (Exception e) { Log.w(TAG, "forward build failed: " + e.getMessage()); if (onComplete != null) { @@ -101,6 +121,12 @@ public final class DebugForwarder { } } + private static void finishOne(AtomicInteger pending, Runnable onComplete) { + if (pending.decrementAndGet() == 0 && onComplete != null) { + onComplete.run(); + } + } + private static String resolveGroup(String title, MessageInfo messageInfo) { if (!TextUtils.isEmpty(title)) { return title.trim(); diff --git a/debug-server/server.py b/debug-server/server.py index 3b26b14..9d85368 100644 --- a/debug-server/server.py +++ b/debug-server/server.py @@ -3,6 +3,7 @@ import json import threading +import time from collections import deque from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -14,6 +15,7 @@ MAX_MESSAGES = 500 _messages = deque(maxlen=MAX_MESSAGES) _lock = threading.Lock() +_last_dedup = {"key": None, "ts": 0.0} def _now_iso(): @@ -32,7 +34,21 @@ def _add_message(payload): item = dict(payload) item["group"] = _resolve_group(payload) item["receivedAt"] = _now_iso() + # 本机 + 局域网双推时可能各成功一次,3 秒内同内容去重 + dedup_key = "|".join([ + str(payload.get("source") or ""), + str(payload.get("packageName") or ""), + str(payload.get("title") or ""), + str(payload.get("content") or ""), + ]) + now = time.time() with _lock: + if (_last_dedup["key"] == dedup_key + and now - float(_last_dedup["ts"]) < 3.0): + if _messages: + return _messages[0] + _last_dedup["key"] = dedup_key + _last_dedup["ts"] = now _messages.appendleft(item) item["id"] = len(_messages) print("[{0}] [{1}] [{2}] {3} | {4}".format( @@ -78,6 +94,394 @@ def _group_messages(messages): return result +def _is_mmp_message(msg): + source = (msg.get("source") or "").lower() + content = msg.get("content") or "" + title = msg.get("title") or "" + if "tng_mmp" in source or "xposed_tng_mmp" in source: + return True + if "[MMP统计]" in content or "Money Packet" in title: + return True + lower = content.lower() + return "receiverlist" in lower or ("claimedamount" in lower and "mmp" in lower) + + +def _normalize_money(value): + """把 1.23 / RM1.23 / {"amount":"0.71","cent":"71"} 统一成 float 或 None。""" + if value is None: + return None + text = str(value).strip() + if not text or text.lower() == "null": + return None + text = text.replace("RM", "").replace("rm", "").strip() + if text.startswith("{") and "amount" in text: + try: + obj = json.loads(text) + if isinstance(obj, dict): + if obj.get("amount") not in (None, ""): + return float(str(obj["amount"]).replace(",", "")) + if obj.get("cent") not in (None, ""): + return int(str(obj["cent"])) / 100.0 + except Exception: + return None + try: + return float(text.replace(",", "")) + except ValueError: + return None + + +def _parse_mmp_content(content): + """解析 TngMoneyPacketHook 转发文本 → {meta, claims[]}。""" + text = (content or "").strip() + meta = {} + claims = [] + if not text: + return meta, claims + lines = text.splitlines() + head = lines[0] if lines else "" + if head.startswith("[MMP统计]"): + head = head[len("[MMP统计]"):].strip() + # 新格式用 " | " 分隔;旧格式兼容空格拆 token + if " | " in head: + parts = [p.strip() for p in head.split(" | ") if p.strip()] + else: + parts = [] + buf = "" + for part in head.split(): + if buf: + buf += " " + part + if buf.count("{") <= buf.count("}"): + parts.append(buf) + buf = "" + continue + if "=" in part and part.split("=", 1)[1].startswith("{") and part.count("{") > part.count("}"): + buf = part + continue + parts.append(part) + if buf: + parts.append(buf) + for part in parts: + if "=" not in part: + continue + k, v = part.split("=", 1) + meta[k.strip()] = v.strip() + # 旧报文 sender=LIAO RUICHAO 被空格截断时,尝试从原文还原 + if meta.get("sender") and "sender=" in head: + raw_sender = head.split("sender=", 1)[1] + for stop in (" | ", " total=", " via=", " group=", " packet=", " src="): + if stop in raw_sender: + raw_sender = raw_sender.split(stop, 1)[0] + break + raw_sender = raw_sender.strip() + if raw_sender and len(raw_sender) > len(meta.get("sender") or ""): + meta["sender"] = raw_sender + if "total" in meta: + total_num = _normalize_money(meta["total"]) + meta["total"] = ("{0:.2f}".format(total_num) if total_num is not None else meta["total"]) + for line in lines[1:]: + line = line.strip() + if not line or "->" not in line: + continue + left, right = line.split("->", 1) + nick = left.strip() + right = right.strip() + claim_time = "" + if right.endswith(")") and "(" in right and not right.startswith("{"): + amt, _, rest = right.partition("(") + amount_raw = amt.strip() + claim_time = rest.rstrip(")").strip() + else: + amount_raw = right + amount_num = _normalize_money(amount_raw) + if nick: + claims.append({ + "nickname": nick, + "amount": ("{0:.2f}".format(amount_num) if amount_num is not None else "0.00"), + "amountValue": amount_num if amount_num is not None else 0.0, + "claimTime": claim_time, + }) + return meta, claims + + +def _claims_fingerprint(claims): + rows = [] + for c in claims: + rows.append("{0}={1}".format(c.get("nickname") or "?", c.get("amount") or "0")) + rows.sort() + return "|".join(rows) + + +def _aggregate_claims(claims): + """同一快照内同昵称合并;跨快照不应再混加(外层只取最新快照)。""" + buckets = {} + order = [] + for c in claims: + nick = c.get("nickname") or "?" + if nick not in buckets: + buckets[nick] = {"nickname": nick, "amount": 0.0, "count": 0, "claimTime": ""} + order.append(nick) + b = buckets[nick] + b["count"] += 1 + try: + b["amount"] += float(c.get("amountValue") if c.get("amountValue") is not None + else _normalize_money(c.get("amount")) or 0.0) + except (TypeError, ValueError): + pass + if c.get("claimTime") and not b["claimTime"]: + b["claimTime"] = c.get("claimTime") or "" + result = [] + for nick in order: + b = buckets[nick] + result.append({ + "nickname": nick, + "amount": round(b["amount"], 4), + "amountText": "{0:.2f}".format(b["amount"]), + "claimCount": b["count"], + "claimTime": b.get("claimTime") or "", + }) + result.sort(key=lambda x: x["amount"], reverse=True) + for i, row in enumerate(result, 1): + row["rank"] = i + return result + + +def _mmp_packets(): + with _lock: + msgs = [m for m in _messages if _is_mmp_message(m)] + # 每个红包只保留「最新一次完整领取榜」快照,避免多个红包因缺 packetId 被揉在一起 + packets = {} + for msg in msgs: + meta, claims = _parse_mmp_content(msg.get("content") or "") + if not claims: + continue + packet_id = meta.get("packet") or meta.get("packetId") or "" + if not packet_id or packet_id in ("unknown", "TNG 红包", "TNG Money Packet"): + # 用领取名单指纹区分不同红包 + packet_id = "红包-" + _claims_fingerprint(claims)[:48] + key = str(packet_id) + item = { + "packetId": key, + "title": msg.get("title") or "TNG 红包", + "sender": meta.get("sender") or "", + "group": meta.get("group") or "", + "total": meta.get("total") or "", + "via": meta.get("via") or "", + "latestAt": msg.get("receivedAt") or "", + "snapshots": 1, + "claims": claims, + "rawMessages": [{ + "id": msg.get("id"), + "receivedAt": msg.get("receivedAt"), + "content": msg.get("content"), + "source": msg.get("source"), + }], + } + existing = packets.get(key) + if existing is None or (item["latestAt"] or "") >= (existing.get("latestAt") or ""): + if existing is not None: + item["snapshots"] = int(existing.get("snapshots") or 1) + 1 + # 保留历史原始报文,但排行只用最新 claims + item["rawMessages"] = existing.get("rawMessages", []) + item["rawMessages"] + packets[key] = item + else: + existing["snapshots"] = int(existing.get("snapshots") or 1) + 1 + existing["rawMessages"].append(item["rawMessages"][0]) + result = [] + for p in packets.values(): + ranked = _aggregate_claims(p.get("claims") or []) + p["leaderboard"] = ranked + p["claimantCount"] = len(ranked) + p["claimEventCount"] = len(p.get("claims") or []) + try: + p["sumClaimed"] = round(sum(x["amount"] for x in ranked), 4) + except Exception: + p["sumClaimed"] = 0 + # 总额缺失或只抓到单笔金额时,用领取合计兜底 + total_num = _normalize_money(p.get("total")) + if total_num is None or (p["sumClaimed"] > 0 and abs(total_num - p["sumClaimed"]) > 0.001 + and total_num <= max((x["amount"] for x in ranked), default=0) + 1e-9): + p["total"] = "{0:.2f}".format(p["sumClaimed"]) + elif total_num is not None: + p["total"] = "{0:.2f}".format(total_num) + del p["claims"] + result.append(p) + result.sort(key=lambda x: x.get("latestAt") or "", reverse=True) + return result + + +def _mmp_html_page(): + html = """ + + + + + 红包领取台 + + + +
+

红包领取台

+ 0 个红包 + - + ← 返回消息台 + + +
+
+ +
+
+ 暂无红包数据
+
在手机打开 TNG「红包」详情或排行榜后,领取列表会显示在这里
+
+ +
+
+ + +""" + return html.encode("utf-8") + + def _html_page(): html = """ @@ -91,6 +495,8 @@ def _html_page(): header { padding: 14px 20px; background: #161b22; border-bottom: 1px solid #30363d; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; flex-shrink: 0; } h1 { margin: 0; font-size: 18px; } .stat { color: #8b949e; font-size: 13px; } + a.nav { color: #58a6ff; text-decoration: none; font-size: 13px; } + a.nav:hover { text-decoration: underline; } button { background: #238636; color: #fff; border: 0; padding: 8px 14px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 13px; } button.secondary { background: #21262d; border: 1px solid #30363d; } .layout { display: flex; flex: 1; min-height: 0; } @@ -117,6 +523,7 @@ def _html_page(): 0 条 0 群 - + 红包领取台 → @@ -237,6 +644,14 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) return + if path == "/mmp": + body = _mmp_html_page() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return if path == "/api/messages": with _lock: data = list(_messages) @@ -247,6 +662,9 @@ class Handler(BaseHTTPRequestHandler): data = _group_messages(list(_messages)) _json_response(self, 200, data) return + if path == "/api/mmp": + _json_response(self, 200, _mmp_packets()) + return if path == "/health": _json_response(self, 200, {"ok": True}) return @@ -298,9 +716,11 @@ class Handler(BaseHTTPRequestHandler): def main(): server = ThreadingHTTPServer((HOST, PORT), Handler) print("notiMessage debug server: http://127.0.0.1:{0}".format(PORT)) - print("浏览器打开上述地址即可查看消息") - print("手机经 USB 调试时先执行: adb reverse tcp:8765 tcp:8765") - print("Wi-Fi 调试时将 AppConfig.DEBUG_SERVER_URL 改为 http://:8765") + print("通用调试台: http://127.0.0.1:{0}/".format(PORT)) + print("红包领取台: http://127.0.0.1:{0}/mmp".format(PORT)) + print("手机经 USB: adb reverse tcp:8765 tcp:8765(走 127.0.0.1)") + print("手机经 Wi-Fi: AppConfig 已含局域网地址,与电脑同一网段即可") + print("App 会对 DEBUG_SERVER_URLS 全部推送;不可达的会失败,可达的生效") try: server.serve_forever() except KeyboardInterrupt: diff --git a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java index 240dd19..3ad60b0 100644 --- a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java +++ b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java @@ -49,6 +49,7 @@ public final class TngMoneyPacketHook { hookOkHttpUrl(lpparam); hookResponseBody(lpparam); hookGsonFromJson(lpparam); + hookQuakeRpc(lpparam); XposedBridge.log(TAG + " installed for " + lpparam.packageName); } @@ -167,11 +168,110 @@ public final class TngMoneyPacketHook { if (TextUtils.isEmpty(className)) { return false; } - return className.contains("MmpDetailResult") - || className.contains("MmpClaimQueryResult") - || className.contains("MmpClaimResult") - || className.contains("MmpReceiver") - || className.contains("MmpDetailLeaderboard"); + String n = className; + return n.contains("MmpDetail") + || n.contains("MmpClaim") + || n.contains("MmpReceiver") + || n.contains("MmpLeaderboard") + || n.contains("MoneyPacketDetail") + || n.contains("MoneyPacketClaim") + || n.contains("MoneyPacketLeader") + || n.contains("MoneyPacketDoClaim") + || n.contains("ClaimResultDto") + || n.contains("ClaimDto") + || n.contains("ReceiverList") + || n.contains("packetReceiver"); + } + + private static void hookQuakeRpc(XC_LoadPackage.LoadPackageParam lpparam) { + String[] rpcClasses = { + "my.com.tngdigital.common.aliservice.quake.TngdRpcInvocationHandlerHost", + "com.alipay.imobile.network.quake.rpc.RpcInvocationHandler", + }; + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (param.hasThrowable() || param.getResult() == null) { + return; + } + String op = extractMoneyPacketRpcOp(param.args); + if (op == null) { + return; + } + Object result = param.getResult(); + String className = result.getClass().getName(); + XposedBridge.log(TAG + " rpc hit op=" + op + " type=" + className); + try { + String json = buildJsonFromObject(result); + if (TextUtils.isEmpty(json)) { + json = tryFastjson(result); + } + if (TextUtils.isEmpty(json)) { + XposedBridge.log(TAG + " rpc serialize empty op=" + op); + return; + } + forwardParsedJson(json, op, "rpc"); + MmpSnapshot probe = parseSnapshot(json); + if (probe == null || probe.claims.isEmpty()) { + String snippet = json.length() > 500 ? json.substring(0, 500) + "..." : json; + XposedBridge.log(TAG + " rpc no-claims op=" + op + " body=" + snippet); + } + } catch (Throwable t) { + XposedBridge.log(TAG + " rpc capture failed: " + t.getMessage()); + } + } + }; + for (String className : rpcClasses) { + try { + Class clazz = XposedHelpers.findClass(className, lpparam.classLoader); + int hooked = 0; + for (Method method : clazz.getDeclaredMethods()) { + if (!"invoke".equals(method.getName())) { + continue; + } + XposedBridge.hookMethod(method, hook); + hooked++; + } + if (hooked > 0) { + XposedBridge.log(TAG + " hooked RPC " + className + " invoke=" + hooked); + } + } catch (Throwable t) { + XposedBridge.log(TAG + " skip RPC " + className + ": " + t.getMessage()); + } + } + } + + private static String extractMoneyPacketRpcOp(Object[] args) { + if (args == null) { + return null; + } + for (Object arg : args) { + if (arg instanceof Method) { + String name = ((Method) arg).getName().toLowerCase(Locale.US); + if (name.contains("moneypacket") || name.contains("mmp")) { + return ((Method) arg).getName(); + } + } else if (arg instanceof String) { + String text = (String) arg; + String lower = text.toLowerCase(Locale.US); + if (lower.contains("moneypacket") || lower.contains(".mmp") + || lower.contains("tngdwallet.money")) { + return text; + } + } + } + return null; + } + + private static String tryFastjson(Object obj) { + try { + Class json = Class.forName("com.alibaba.fastjson.JSON", false, + obj.getClass().getClassLoader()); + Object out = XposedHelpers.callStaticMethod(json, "toJSONString", obj); + return out != null ? String.valueOf(out) : null; + } catch (Throwable ignored) { + return null; + } } private static void forwardParsedJson(String json, String sourceHint, String channel) { @@ -185,9 +285,11 @@ public final class TngMoneyPacketHook { } Context context = getContext(); if (context == null) { + XposedBridge.log(TAG + " skip forward: no context, claims=" + + snapshot.claims.size()); return; } - String title = TextUtils.isEmpty(snapshot.title) ? "TNG Money Packet" : snapshot.title; + String title = TextUtils.isEmpty(snapshot.title) ? "TNG 红包" : snapshot.title; String content = snapshot.formatForForward(channel, sourceHint); HookForwarder.forward(context, PACKAGE, title, content, HookBridge.SOURCE_XPOSED_TNG_MMP); XposedBridge.log(TAG + " captured packet=" + snapshot.packetId @@ -205,7 +307,11 @@ public final class TngMoneyPacketHook { private static MmpSnapshot parseSnapshot(JSONObject root, MmpSnapshot base) { MmpSnapshot snapshot = base != null ? base : new MmpSnapshot(); - fillMeta(root, snapshot); + boolean rootPass = base == null; + // 领取行不再回填 meta;其它嵌套节点可补 packetId/sender/total + if (rootPass || parseClaimLine(root) == null) { + fillMeta(root, snapshot); + } JSONArray receiverList = findReceiverList(root); if (receiverList != null) { @@ -233,7 +339,7 @@ public final class TngMoneyPacketHook { Object elem = arr.opt(i); if (elem instanceof JSONObject) { ClaimLine line = parseClaimLine((JSONObject) elem); - if (line != null && looksLikeClaimRow((JSONObject) elem)) { + if (line != null) { snapshot.claims.add(line); } parseSnapshot((JSONObject) elem, snapshot); @@ -241,6 +347,9 @@ public final class TngMoneyPacketHook { } } } + if (rootPass && TextUtils.isEmpty(snapshot.packetId) && !snapshot.claims.isEmpty()) { + snapshot.packetId = "fp" + Integer.toHexString(snapshot.dedupKey().hashCode()); + } return snapshot; } @@ -249,7 +358,7 @@ public final class TngMoneyPacketHook { while (keys.hasNext()) { String key = keys.next(); Object val = obj.opt(key); - if ("receiverList".equalsIgnoreCase(key) && val instanceof JSONArray) { + if (val instanceof JSONArray && isReceiverListKey(key)) { return (JSONArray) val; } if (val instanceof JSONObject) { @@ -262,12 +371,32 @@ public final class TngMoneyPacketHook { return null; } + private static boolean isReceiverListKey(String key) { + if (TextUtils.isEmpty(key)) { + return false; + } + String k = key.toLowerCase(Locale.US); + return "receiverlist".equals(k) + || "leaderboardlist".equals(k) + || "leaderboard".equals(k) + || "rankinglist".equals(k) + || "claimlist".equals(k) + || "claimers".equals(k) + || "packetreceiverlist".equals(k) + || "receivers".equals(k) + || k.contains("receiverlist") + || k.contains("leaderboard"); + } + private static void fillMeta(JSONObject obj, MmpSnapshot snapshot) { - putIfPresent(obj, snapshot, "packetId", "mmpId", "moneyPacketId", "id"); + // 勿用裸 amount/id:会把领取金额或嵌套 id 误当成红包总额/编号 + putIfPresent(obj, snapshot, "packetId", "mmpId", "moneyPacketId", "packetCode", + "fundOrderId", "orderNo", "bizNo", "bizOrderId", "activityId"); putIfPresent(obj, snapshot, "groupId", "chatId", "conversationId"); putIfPresent(obj, snapshot, "groupName", "chatName", "conversationName"); - putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName"); - putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "amount"); + putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName", "creatorName"); + putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "packetTotalAmount", + "totalPacketAmount", "originalAmount"); if (TextUtils.isEmpty(snapshot.title)) { String merchant = firstNonEmpty( obj.optString("merchantName", null), @@ -283,70 +412,142 @@ public final class TngMoneyPacketHook { if (!obj.has(key)) { continue; } - String val = obj.optString(key, null); + String val = readFieldText(obj, key); if (TextUtils.isEmpty(val) || "null".equalsIgnoreCase(val)) { continue; } - if (key.toLowerCase(Locale.US).contains("packet") || key.equals("mmpId") || key.equals("id")) { - if (TextUtils.isEmpty(snapshot.packetId)) { + String keyLower = key.toLowerCase(Locale.US); + if (keyLower.contains("packet") || keyLower.contains("mmp") + || keyLower.contains("order") || keyLower.contains("biz") + || keyLower.contains("activity") || keyLower.equals("packetcode")) { + if (TextUtils.isEmpty(snapshot.packetId) && !looksLikeMoneyJson(val)) { snapshot.packetId = val; } - } else if (key.toLowerCase(Locale.US).contains("group")) { - if (TextUtils.isEmpty(snapshot.groupId)) { + } else if (keyLower.contains("group") || keyLower.contains("chat") + || keyLower.contains("conversation")) { + if (keyLower.contains("name")) { + if (TextUtils.isEmpty(snapshot.title)) { + snapshot.title = val; + } + } else if (TextUtils.isEmpty(snapshot.groupId)) { snapshot.groupId = val; } - } else if (key.toLowerCase(Locale.US).contains("name") && key.toLowerCase(Locale.US).contains("group")) { - snapshot.title = val; - } else if (key.toLowerCase(Locale.US).contains("sender") - || key.toLowerCase(Locale.US).contains("operator")) { - if (TextUtils.isEmpty(snapshot.senderName)) { + } else if (keyLower.contains("sender") || keyLower.contains("operator") + || keyLower.contains("creator")) { + if (TextUtils.isEmpty(snapshot.senderName) && !looksLikeMoneyJson(val)) { snapshot.senderName = val; } - } else if (key.toLowerCase(Locale.US).contains("total") || key.equals("amount")) { - if (TextUtils.isEmpty(snapshot.totalAmount)) { - snapshot.totalAmount = val; + } else if (keyLower.contains("total") || keyLower.contains("packetamount") + || keyLower.contains("original")) { + String money = normalizeMoneyText(val); + if (TextUtils.isEmpty(snapshot.totalAmount) && !TextUtils.isEmpty(money)) { + snapshot.totalAmount = money; } } } } private static boolean looksLikeClaimRow(JSONObject obj) { - String name = firstNonEmpty( - obj.optString("nickName", null), - obj.optString("displayName", null), - obj.optString("userName", null), - obj.optString("receiverName", null)); - String amount = firstNonEmpty( - obj.optString("claimedAmount", null), - obj.optString("receiveAmount", null), - obj.optString("amount", null)); - return !TextUtils.isEmpty(name) && !TextUtils.isEmpty(amount); + return parseClaimLine(obj) != null; } private static ClaimLine parseClaimLine(JSONObject obj) { String name = firstNonEmpty( - obj.optString("nickName", null), - obj.optString("displayName", null), - obj.optString("userName", null), - obj.optString("receiverName", null), - obj.optString("name", null)); + readFieldText(obj, "nickName"), + readFieldText(obj, "nickname"), + readFieldText(obj, "displayName"), + readFieldText(obj, "userName"), + readFieldText(obj, "receiverName"), + readFieldText(obj, "receiverAccountName"), + readFieldText(obj, "accountName"), + readFieldText(obj, "participantName"), + readFieldText(obj, "name")); String amount = firstNonEmpty( - obj.optString("claimedAmount", null), - obj.optString("receiveAmount", null), - obj.optString("amount", null)); + normalizeMoneyText(readFieldText(obj, "claimedAmount")), + normalizeMoneyText(readFieldText(obj, "receiveAmount")), + normalizeMoneyText(readFieldText(obj, "receiverClaimedAmount")), + normalizeMoneyText(readFieldText(obj, "claimAmount")), + normalizeMoneyText(readFieldText(obj, "amount"))); if (TextUtils.isEmpty(name) || TextUtils.isEmpty(amount)) { return null; } + if (name.length() > 64 || looksLikeMoneyJson(name)) { + return null; + } ClaimLine line = new ClaimLine(); - line.nickname = name; + line.nickname = name.trim(); line.amount = amount; line.claimTime = firstNonEmpty( - obj.optString("claimTime", null), - obj.optString("claimedTime", null), - obj.optString("receiveTime", null)); + readFieldText(obj, "claimTime"), + readFieldText(obj, "claimedTime"), + readFieldText(obj, "receiveTime"), + readFieldText(obj, "gmtClaim"), + readFieldText(obj, "gmtCreate"), + readFieldText(obj, "time")); return line; } + /** 读取字段:兼容 Money 对象 / 嵌套 JSON / 普通字符串。 */ + private static String readFieldText(JSONObject obj, String key) { + if (obj == null || TextUtils.isEmpty(key) || !obj.has(key)) { + return null; + } + Object raw = obj.opt(key); + if (raw == null || raw == JSONObject.NULL) { + return null; + } + if (raw instanceof JSONObject) { + return normalizeMoneyText(((JSONObject) raw).toString()); + } + String text = String.valueOf(raw).trim(); + if (TextUtils.isEmpty(text) || "null".equalsIgnoreCase(text)) { + return null; + } + if (looksLikeMoneyJson(text)) { + return normalizeMoneyText(text); + } + return text; + } + + private static boolean looksLikeMoneyJson(String text) { + if (TextUtils.isEmpty(text)) { + return false; + } + String t = text.trim(); + return t.startsWith("{") && t.contains("\"amount\""); + } + + private static String normalizeMoneyText(String text) { + if (TextUtils.isEmpty(text) || "null".equalsIgnoreCase(text)) { + return null; + } + String t = text.trim().replace("RM", "").trim(); + if (looksLikeMoneyJson(t)) { + try { + JSONObject money = new JSONObject(t); + String amount = money.optString("amount", null); + if (!TextUtils.isEmpty(amount) && !"null".equalsIgnoreCase(amount)) { + return amount.trim(); + } + String cent = money.optString("cent", null); + if (!TextUtils.isEmpty(cent) && !"null".equalsIgnoreCase(cent)) { + try { + return String.format(Locale.US, "%.2f", Integer.parseInt(cent) / 100.0); + } catch (Throwable ignored) { + } + } + } catch (Throwable ignored) { + } + return null; + } + // 纯数字或 1.00 + try { + return String.format(Locale.US, "%.2f", Double.parseDouble(t.replace(",", ""))); + } catch (Throwable ignored) { + return t; + } + } + private static String buildJsonFromObject(Object root) { JSONObject json = objectToJson(root, new HashSet(), 0); return json != null ? json.toString() : null; @@ -541,23 +742,24 @@ public final class TngMoneyPacketHook { String formatForForward(String channel, String sourceHint) { StringBuilder sb = new StringBuilder(); - sb.append("[MMP统计] "); + sb.append("[MMP统计]"); if (!TextUtils.isEmpty(packetId)) { - sb.append("packet=").append(packetId).append(' '); + sb.append(" packet=").append(sanitizeMeta(packetId)); } if (!TextUtils.isEmpty(groupId)) { - sb.append("group=").append(groupId).append(' '); + sb.append(" | group=").append(sanitizeMeta(groupId)); } if (!TextUtils.isEmpty(senderName)) { - sb.append("sender=").append(senderName).append(' '); + sb.append(" | sender=").append(sanitizeMeta(senderName)); } if (!TextUtils.isEmpty(totalAmount)) { - sb.append("total=").append(totalAmount).append(' '); + sb.append(" | total=").append(sanitizeMeta(totalAmount)); } - sb.append("via=").append(channel); + sb.append(" | via=").append(sanitizeMeta(channel)); if (!TextUtils.isEmpty(sourceHint)) { - sb.append(" src=").append(sourceHint.length() > 120 - ? sourceHint.substring(0, 120) + "..." : sourceHint); + String src = sourceHint.length() > 120 + ? sourceHint.substring(0, 120) + "..." : sourceHint; + sb.append(" | src=").append(sanitizeMeta(src)); } sb.append("\n"); for (ClaimLine line : claims) { @@ -569,6 +771,14 @@ public final class TngMoneyPacketHook { } return sb.toString().trim(); } + + private static String sanitizeMeta(String value) { + if (value == null) { + return ""; + } + // 避免空格拆坏 meta;竖线是分隔符 + return value.replace('|', '/').trim(); + } } private static final class ClaimLine {