feat(tng): 自动拉红包历史/详情,并展示发放与领取时间

从 ILoginStorage 与官方历史页缓存请求模板,级联拉详情;领取台列表用发放时间而非拉取时间。
This commit is contained in:
mars
2026-08-04 16:20:13 +08:00
parent 1e2021d8fd
commit 1517ff4671
4 changed files with 873 additions and 128 deletions

View File

@@ -2,6 +2,7 @@
"""notiMessage 本地调试转发服务 — 在 PC 浏览器查看手机抓取的消息。"""
import json
import re
import threading
import time
from collections import deque
@@ -178,6 +179,11 @@ def _parse_mmp_content(content):
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"])
# 发放时间issued= / createTime= / issuedAt=
for key in ("issued", "createTime", "issuedAt", "create"):
if meta.get(key):
meta["issued"] = meta[key]
break
for line in lines[1:]:
line = line.strip()
if not line or "->" not in line:
@@ -212,22 +218,28 @@ def _claims_fingerprint(claims):
def _aggregate_claims(claims):
"""同一快照内同昵称合并;跨快照不应再混加(外层只取最新快照)"""
"""同一快照内同昵称只保留一笔(取较大金额),红包每人只领一次,禁止累加导致翻倍"""
buckets = {}
order = []
for c in claims:
nick = c.get("nickname") or "?"
try:
amt = float(c.get("amountValue") if c.get("amountValue") is not None
else _normalize_money(c.get("amount")) or 0.0)
except (TypeError, ValueError):
amt = 0.0
if nick not in buckets:
buckets[nick] = {"nickname": nick, "amount": 0.0, "count": 0, "claimTime": ""}
buckets[nick] = {"nickname": nick, "amount": amt, "count": 1,
"claimTime": c.get("claimTime") or ""}
order.append(nick)
continue
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"]:
if amt >= b["amount"]:
b["amount"] = amt
if c.get("claimTime"):
b["claimTime"] = c.get("claimTime") or b["claimTime"]
elif c.get("claimTime") and not b["claimTime"]:
b["claimTime"] = c.get("claimTime") or ""
result = []
for nick in order:
@@ -259,6 +271,7 @@ def _mmp_packets():
# 用领取名单指纹区分不同红包
packet_id = "红包-" + _claims_fingerprint(claims)[:48]
key = str(packet_id)
issued = meta.get("issued") or ""
item = {
"packetId": key,
"title": msg.get("title") or "TNG 红包",
@@ -266,7 +279,9 @@ def _mmp_packets():
"group": meta.get("group") or "",
"total": meta.get("total") or "",
"via": meta.get("via") or "",
"latestAt": msg.get("receivedAt") or "",
"issuedAt": issued,
"fetchedAt": msg.get("receivedAt") or "",
"latestAt": issued or (msg.get("receivedAt") or ""),
"snapshots": 1,
"claims": claims,
"rawMessages": [{
@@ -277,15 +292,27 @@ def _mmp_packets():
}],
}
existing = packets.get(key)
if existing is None or (item["latestAt"] or "") >= (existing.get("latestAt") or ""):
# 优先保留「有发放时间 + 领取更全」的快照;时间戳用发放时间排序
def _score(it):
return (
1 if it.get("issuedAt") else 0,
len(it.get("claims") or []),
it.get("fetchedAt") or "",
)
if existing is None or _score(item) >= _score(existing):
if existing is not None:
item["snapshots"] = int(existing.get("snapshots") or 1) + 1
# 保留历史原始报文,但排行只用最新 claims
item["rawMessages"] = existing.get("rawMessages", []) + item["rawMessages"]
if not item.get("issuedAt") and existing.get("issuedAt"):
item["issuedAt"] = existing["issuedAt"]
item["latestAt"] = item["issuedAt"] or item.get("fetchedAt") or ""
packets[key] = item
else:
existing["snapshots"] = int(existing.get("snapshots") or 1) + 1
existing["rawMessages"].append(item["rawMessages"][0])
if not existing.get("issuedAt") and issued:
existing["issuedAt"] = issued
existing["latestAt"] = issued
result = []
for p in packets.values():
ranked = _aggregate_claims(p.get("claims") or [])
@@ -305,10 +332,19 @@ def _mmp_packets():
p["total"] = "{0:.2f}".format(total_num)
del p["claims"]
result.append(p)
result.sort(key=lambda x: x.get("latestAt") or "", reverse=True)
result.sort(key=lambda x: _mmp_time_sort_key(x.get("issuedAt") or x.get("latestAt") or ""), reverse=True)
return result
def _mmp_time_sort_key(text):
"""把 13/07/2026 11:54:44 / ISO 等统一成可比较字符串。"""
s = (text or "").strip()
m = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:\s+(\d{2}:\d{2}(?::\d{2})?))?", s)
if m:
return "{0}-{1}-{2} {3}".format(m.group(3), m.group(2), m.group(1), m.group(4) or "")
return s
def _mmp_html_page():
html = """<!DOCTYPE html>
<html lang="zh-CN">
@@ -371,7 +407,7 @@ def _mmp_html_page():
<main class="main">
<div id="empty" class="empty">
暂无红包数据<br/>
<div class="hint">打开 Money Packet 历史页可自动拉取;若没有数据,先手动点开任意一条详情一次(缓存登录模板),再回历史页</div>
<div class="hint">登录 TNG 后停留任意页面即可自动拉历史并拉详情;也可打开 Money Packet 历史页加速。无需再手动点详情。</div>
</div>
<div id="panel" style="display:none">
<h2 class="title" id="title">-</h2>
@@ -420,7 +456,7 @@ def _mmp_html_page():
div.onclick = () => { activeId = p.packetId; loadData(); };
div.innerHTML = `
<div class="pkt-id">${esc(shortId(p.packetId))} · ${esc((p.sender || '红包') + ' RM' + (p.sumClaimed ?? p.total ?? 0))}</div>
<div class="pkt-meta">${p.claimantCount || 0} 人领取 · ${esc(p.latestAt || '')}</div>`;
<div class="pkt-meta">${p.claimantCount || 0} 人领取 · ${esc(p.issuedAt ? ('发放 ' + p.issuedAt) : '发放时间未知')}</div>`;
list.appendChild(div);
}
renderActive();
@@ -440,6 +476,7 @@ def _mmp_html_page():
const parts = [];
if (p.packetId) parts.push('activityId ' + p.packetId);
if (p.sender) parts.push('发送人 ' + p.sender);
if (p.issuedAt) parts.push('发放 ' + p.issuedAt);
if (p.group) parts.push('群组 ' + p.group);
if (p.via) parts.push('来源 ' + (p.via === 'http' ? '网络' : p.via === 'gson' ? '解析' : p.via === 'rpc' ? 'RPC' : p.via));
document.getElementById('sub').textContent = parts.join(' · ') || '单个红包领取排行';

View File

@@ -55,31 +55,35 @@ powershell -ExecutionPolicy Bypass -File scripts/start-debug-server.ps1
消费端详情领取列表字段主要是 **`activityPoolInfos`**`receiverName` + `amount`),不是商户版的 `receiverList`
## 推荐操作流程(自动拉详情)
## 推荐操作流程(自动拉历史 + 详情)
历史页本身没有排行数据。自动拉取需要一次「带登录态的详情请求」作模板
登录后**无需再手动点详情灌模板**,也**不必反复进历史页**
1. 打开 TNG **Money Packet**
2. **先手动点开任意一条历史红包详情**(看到排行榜即可
- 日志:`cached detail request template`(缓存 session / 请求模板)
3. **返回历史列表**,停留几秒
- 日志:`history hit → schedule auto detail`
- 随后:`auto-detail using template` / `auto-detail ok activityId=... claims=N`
4. 刷新领取台http://127.0.0.1:8765/mmp
1. 打开 TNG 并完成登录 / PIN任意前台页面即可
2. Hook 从 `ILoginStorage` / 历史请求自动取 `sessionId``Activity.onResume` 约每 **45 秒**主动调一次 `moneyPacketHistoryList`(主线程
3. 历史返回后自动按 `activityId` 在主线程串行拉详情(间隔约 200ms推送到领取台
4. 仅打开 **Money Packet 历史页**时App 自己的列表响应也会立刻触发步骤 3
5. 打开 http://127.0.0.1:8765/mmp 刷新查看
若跳过第 2 步,自动请求常返回 `totalCount:0`(缺 `sessionId` 等),台上会没有领取人。
日志期望:
也可继续「只点详情」:每进一个详情就会捕获该 `activityId` 的排行。
- `hooked ILoginStorage getters` / `login from storage session=…` / `cached history request template`
- `history hit → schedule auto detail``auto-history ok → schedule details`
- `auto-detail ok activityId=… claims=N`
若仍无数据:确认已安装最新 Xposed 模块并强停重启 TNG看是否出现 `auto-detail empty claims` / `no sessionId`
## 去重与刷新行为
| 规则 | 说明 |
|------|------|
| 标识 | 以 `activityId` 区分红包 |
| 短时去重 | 同一 `activityId` + **相同领取名单**,约 **20 秒内**不重复推送(防双 Hook / 连点) |
| 可刷新 | 超过 20 秒再进详情,或领取名单变化,会再次推送 |
| sessionId | 自动:`ILoginStorage` / Repository.loginStorage有真实请求时也会缓存 |
| 自动历史 | 前台 `onResume` 触发,约 **45 秒**冷却;也可由首次缓存到 RpcTask 触发 |
| 短时去重 | 同一 `activityId` + **相同领取名单**,约 **20 秒内**不重复推送 |
| 自动拉冷却 | 同一 `activityId` 自动拉详情成功后约 **60 秒**内不重复自动拉 |
| 调试台 | 同一 `activityId` 只保留最新完整快照做排行;双通道推送有短时内容去重 |
| 包间间隔 | 自动拉详情串行,约 **150ms**/条 |
| 调试台 | 同一 `activityId` 只保留最新完整快照;双通道短时内容去重 |
日志里若出现 `skip dup activityId=...`,表示短时去重生效,不是没抓到。
@@ -95,10 +99,11 @@ adb logcat | findstr TngMmp
|------|------|
| `TngMmp installed` | Hook 已加载 |
| `cached RPC task MoneyPacketRpcTask` | 已缓存 RPC 代理 |
| `cached detail request template` | 已缓存手动详情请求(自动拉可用 |
| `history hit → schedule auto detail` | 历史列表触发自动拉 |
| `auto-detail empty claims ... totalCount:0` | 请求缺登录态或模板未缓存 |
| `auto-detail ok activityId=... claims=N` | 自动拉成功 |
| `cached detail request template` | 已缓存手动详情请求(可选加速 |
| `login from storage session=…` | 已从 ILoginStorage 自动取到 session |
| `auto-history ok → schedule details` | 已自动拉历史列表 |
| `auto-detail empty claims ... totalCount:0` | 请求缺登录态或服务端空结果 |
| `auto-detail ok activityId=... claims=N` | 自动拉详情成功 |
| `captured activityId=... claims=N` | 已转发到 notiMessage / 调试台 |
其它检查:

View File

@@ -26,8 +26,9 @@
### 要点
1. 历史列表只有摘要;领取排行来自详情 RPC`activityPoolInfos`)。
2. **自动拉详情**:先手动点开任意一条详情缓存请求模板(含 session再回历史页即可批量拉
3. 去重:同一 `activityId` + 相同领取名单约 20s 内不重复推。
2. **sessionId**`ILoginStorage` 自动读取,一般不必再手动点详情
3. **自动拉历史**:前台 Activity resume约 45s 冷却)主动调 `moneyPacketHistoryList`,再级联拉详情。
4. 去重:同一 `activityId` + 相同领取名单约 20s 内不重复推。
### eKYC「验证您的帐户」强制页2026-08-04

View File

@@ -1,6 +1,8 @@
package com.miraclegarden.smsmessage.xposed.hook;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import com.miraclegarden.smsmessage.xposed.HookBridge;
@@ -49,13 +51,27 @@ public final class TngMoneyPacketHook {
private static final java.util.HashSet<String> AUTO_DETAIL_PENDING = new java.util.HashSet<>();
private static final java.util.HashMap<String, Long> AUTO_DETAIL_AT = new java.util.HashMap<>();
private static final long AUTO_DETAIL_COOLDOWN_MS = 60_000L;
/** activityId → 历史列表里的发放时间 createTime */
private static final java.util.HashMap<String, String> ACTIVITY_ISSUE_TIME =
new java.util.HashMap<>();
/** 自动拉历史列表冷却(无需打开历史页) */
private static final long AUTO_HISTORY_COOLDOWN_MS = 45_000L;
private static volatile long sLastAutoHistoryAt;
private static volatile boolean sAutoHistoryRunning;
/** 本线程正在主动拉历史,避免 Quake afterHook 再 schedule 一遍 */
private static final ThreadLocal<Boolean> AUTO_HISTORY_SELF = new ThreadLocal<>();
private static volatile Object sMoneyPacketRepository;
private static volatile Object sMoneyPacketRpcTask;
private static volatile ClassLoader sAppClassLoader;
private static volatile String sCachedUserId;
private static volatile String sCachedSessionId;
private static volatile String sCachedLoginId;
/** 手动打开详情时抓到的真实请求,自动拉详情时克隆它 */
/** 真实详情/历史请求模板(有则克隆;无则用 ILoginStorage 自造) */
private static volatile Object sTemplateDetailRequest;
private static volatile Object sTemplateHistoryRequest;
/** 最近一次带登录态的请求(历史/详情),用于给自造详情请求补 session */
private static volatile Object sLoginDonorRequest;
private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
private static final java.util.concurrent.ExecutorService AUTO_DETAIL_EXEC =
java.util.concurrent.Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "TngMmp-auto-detail");
@@ -75,6 +91,8 @@ public final class TngMoneyPacketHook {
hookResponseBody(lpparam);
hookGsonFromJson(lpparam);
hookQuakeRpc(lpparam);
hookLoginStorage(lpparam);
hookActivityResumeForAutoHistory(lpparam);
XposedBridge.log(TAG + " installed for " + lpparam.packageName);
}
@@ -209,9 +227,10 @@ public final class TngMoneyPacketHook {
}
private static void hookQuakeRpc(XC_LoadPackage.LoadPackageParam lpparam) {
// 只挂 TNG 封装层,避免与底层 RpcInvocationHandler 双触发导致重复
// TNG 封装 + Quake 底层都挂,避免历史/详情只走其中一条链路时漏抓
String[] rpcClasses = {
"my.com.tngdigital.common.aliservice.quake.TngdRpcInvocationHandlerHost",
"com.alipay.imobile.network.quake.rpc.RpcInvocationHandler",
};
XC_MethodHook hook = new XC_MethodHook() {
@Override
@@ -225,9 +244,25 @@ public final class TngMoneyPacketHook {
if (op == null) {
return;
}
XposedBridge.log(TAG + " rpc op=" + op
+ " via=" + param.thisObject.getClass().getSimpleName());
Object result = param.getResult();
// 历史列表:无领取排行,但可用 activityId 自动拉详情
if (isHistoryRpcOp(op)) {
Object historyReq = extractRpcRequestArg(param.args);
if (historyReq != null) {
sTemplateHistoryRequest = cloneRequestShallow(historyReq);
sLoginDonorRequest = historyReq;
cacheLoginFromRequestObject(historyReq);
XposedBridge.log(TAG + " cached history request template"
+ " session=" + abbreviate(stringField(historyReq,
"sessionId", "getSessionId")));
}
// 无论是否自己触发的历史请求,都先缓存发放时间
cacheIssueTimesFromHistory(result);
if (Boolean.TRUE.equals(AUTO_HISTORY_SELF.get())) {
return;
}
XposedBridge.log(TAG + " history hit → schedule auto detail");
scheduleAutoDetailsFromHistory(result);
return;
@@ -248,9 +283,11 @@ public final class TngMoneyPacketHook {
return;
}
MmpSnapshot probe = parseSnapshot(json);
enrichSnapshotFromJavaResult(result, probe);
if (probe != null && !probe.claims.isEmpty()) {
if (detailReq != null) {
sTemplateDetailRequest = cloneRequestShallow(detailReq);
sLoginDonorRequest = detailReq;
cacheLoginFromRequestObject(detailReq);
XposedBridge.log(TAG + " cached detail request template"
+ " userId=" + stringField(detailReq, "userId", "getUserId")
@@ -259,7 +296,7 @@ public final class TngMoneyPacketHook {
+ " page=" + stringField(detailReq, "page", "getPage")
+ " max=" + stringField(detailReq, "maxResult", "getMaxResult"));
}
forwardParsedJson(json, op, "rpc");
forwardSnapshot(probe, op, "rpc");
} else {
String snippet = json.length() > 500 ? json.substring(0, 500) + "..." : json;
XposedBridge.log(TAG + " rpc no-claims op=" + op + " body=" + snippet);
@@ -287,6 +324,105 @@ public final class TngMoneyPacketHook {
XposedBridge.log(TAG + " skip RPC " + className + ": " + t.getMessage());
}
}
hookMoneyPacketRepository(lpparam);
}
/** DI 创建 Repository 时立刻缓存 task + login不必等用户进红包页 */
private static void hookMoneyPacketRepository(XC_LoadPackage.LoadPackageParam lpparam) {
try {
Class<?> repoCls = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.data.repo.MoneyPacketRepositoryImpl",
lpparam.classLoader);
XposedBridge.hookAllConstructors(repoCls, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
captureRepoInstance(param.thisObject, "ctor");
}
});
// Dagger 可能先无参构造再注入字段;在业务方法入口再捕获一次
for (String methodName : new String[]{
"getMoneyPacketList", "getMoneyPacketDetail", "doClaim", "preClaim"}) {
try {
for (Method m : repoCls.getDeclaredMethods()) {
if (!methodName.equals(m.getName())) {
continue;
}
XposedBridge.hookMethod(m, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
captureRepoInstance(param.thisObject, "method:" + methodName);
}
});
}
} catch (Throwable ignored) {
}
}
XposedBridge.log(TAG + " hooked MoneyPacketRepositoryImpl");
} catch (Throwable t) {
XposedBridge.log(TAG + " repo hook skip: " + t.getMessage());
}
}
private static void captureRepoInstance(Object repo, String reason) {
if (repo == null) {
return;
}
try {
sMoneyPacketRepository = repo;
Object task = null;
try {
task = XposedHelpers.getObjectField(repo, "task");
} catch (Throwable ignored) {
}
if (task != null && sMoneyPacketRpcTask == null) {
sMoneyPacketRpcTask = task;
XposedBridge.log(TAG + " cached RPC task from repo (" + reason + ")");
}
Object loginStorage = null;
try {
loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
} catch (Throwable ignored) {
}
applyLoginStorage(loginStorage);
if (sMoneyPacketRpcTask != null && !TextUtils.isEmpty(sCachedSessionId)) {
scheduleAutoHistory("repo-" + reason);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " captureRepo fail: " + t.getMessage());
}
}
/** 与 App 一致:发 RPC 前走 Repository.addNetworkHandle否则易 RpcApiException / 空结果 */
private static void prepareRpcRequest(Object req) {
if (req == null) {
return;
}
Object repo = sMoneyPacketRepository;
if (repo == null) {
repo = obtainMoneyPacketRepository(sAppClassLoader);
if (repo != null) {
sMoneyPacketRepository = repo;
}
}
if (repo == null) {
XposedBridge.log(TAG + " prepareRpc skip: no repository");
return;
}
try {
// addNetworkHandle$default(repo, request, processor=null, onError=null, mask=3, marker=null)
XposedHelpers.callStaticMethod(repo.getClass(), "addNetworkHandle$default",
repo, req, null, null, 3, null);
XposedBridge.log(TAG + " prepareRpc addNetworkHandle$default ok");
return;
} catch (Throwable t) {
XposedBridge.log(TAG + " addNetworkHandle$default fail: " + t.getMessage());
}
try {
XposedHelpers.callMethod(repo, "addNetworkHandle", req, null, null);
XposedBridge.log(TAG + " prepareRpc addNetworkHandle ok");
} catch (Throwable t) {
XposedBridge.log(TAG + " addNetworkHandle fail: " + t.getMessage());
}
}
private static boolean isHistoryRpcOp(String op) {
@@ -331,6 +467,7 @@ public final class TngMoneyPacketHook {
if (name.endsWith("MoneyPacketRpcTask") || name.contains("MoneyPacketRpc")) {
sMoneyPacketRpcTask = proxy;
XposedBridge.log(TAG + " cached RPC task " + name);
scheduleAutoHistory("rpc-task-cached");
return;
}
}
@@ -341,6 +478,7 @@ public final class TngMoneyPacketHook {
sAppClassLoader));
sMoneyPacketRpcTask = proxy;
XposedBridge.log(TAG + " cached RPC task via moneyPacketDetail method");
scheduleAutoHistory("rpc-task-cached");
} catch (Throwable ignored) {
}
} catch (Throwable ignored) {
@@ -456,46 +594,60 @@ public final class TngMoneyPacketHook {
if (historyResult == null) {
return;
}
AUTO_DETAIL_EXEC.execute(() -> {
try {
List<String[]> jobs = extractHistoryJobs(historyResult);
XposedBridge.log(TAG + " auto-detail jobs=" + jobs.size());
for (String[] job : jobs) {
final String activityId = job[0];
final String senderUserId = job[1];
long now = System.currentTimeMillis();
synchronized (AUTO_DETAIL_AT) {
Long last = AUTO_DETAIL_AT.get(activityId);
if (last != null && now - last < AUTO_DETAIL_COOLDOWN_MS) {
continue;
}
if (!AUTO_DETAIL_PENDING.add(activityId)) {
continue;
}
// 解析放调用线程;真正 RPC 必须丢到主线程Quake/登录态常绑主线程)
final List<String[]> jobs;
try {
jobs = extractHistoryJobs(historyResult);
} catch (Throwable t) {
XposedBridge.log(TAG + " extract history jobs failed: " + t.getMessage());
return;
}
XposedBridge.log(TAG + " auto-detail jobs=" + jobs.size());
if (jobs.isEmpty()) {
return;
}
long delayMs = 0L;
for (String[] job : jobs) {
final String activityId = job[0];
final String senderUserId = job[1];
final String createTime = job.length > 2 ? job[2] : null;
if (!TextUtils.isEmpty(createTime)) {
synchronized (ACTIVITY_ISSUE_TIME) {
ACTIVITY_ISSUE_TIME.put(activityId, createTime);
}
}
long now = System.currentTimeMillis();
synchronized (AUTO_DETAIL_AT) {
Long last = AUTO_DETAIL_AT.get(activityId);
if (last != null && now - last < AUTO_DETAIL_COOLDOWN_MS) {
continue;
}
if (!AUTO_DETAIL_PENDING.add(activityId)) {
continue;
}
}
final long thisDelay = delayMs;
delayMs += 250L;
AUTO_DETAIL_EXEC.execute(() -> {
try {
if (thisDelay > 0) {
Thread.sleep(thisDelay);
}
try {
boolean ok = fetchDetailByActivityId(activityId, senderUserId);
if (ok) {
synchronized (AUTO_DETAIL_AT) {
AUTO_DETAIL_AT.put(activityId, System.currentTimeMillis());
}
}
} finally {
boolean ok = fetchDetailByActivityId(activityId, senderUserId);
if (ok) {
synchronized (AUTO_DETAIL_AT) {
AUTO_DETAIL_PENDING.remove(activityId);
AUTO_DETAIL_AT.put(activityId, System.currentTimeMillis());
}
}
try {
Thread.sleep(400);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
} finally {
synchronized (AUTO_DETAIL_AT) {
AUTO_DETAIL_PENDING.remove(activityId);
}
}
} catch (Throwable t) {
XposedBridge.log(TAG + " auto-detail schedule failed: " + t.getMessage());
}
});
});
}
}
private static List<String[]> extractHistoryJobs(Object historyResult) {
@@ -521,8 +673,10 @@ public final class TngMoneyPacketHook {
}
String activityId = stringField(info, "activityId", "getActivityId");
String senderUserId = stringField(info, "senderUserId", "getSenderUserId");
String createTime = stringField(info, "createTime", "getCreateTime");
if (looksLikeActivityId(activityId) && !TextUtils.isEmpty(senderUserId)) {
jobs.add(new String[]{activityId, senderUserId});
jobs.add(new String[]{activityId, senderUserId,
TextUtils.isEmpty(createTime) ? "" : createTime});
}
}
} catch (Throwable t) {
@@ -551,6 +705,7 @@ public final class TngMoneyPacketHook {
private static boolean fetchDetailByActivityId(String activityId, String senderUserId) {
ClassLoader cl = sAppClassLoader;
ensureRpcTaskAndLogin(cl);
Object task = sMoneyPacketRpcTask;
if (cl == null || task == null) {
XposedBridge.log(TAG + " auto-detail skip: no rpc task yet, id=" + activityId);
@@ -572,25 +727,20 @@ public final class TngMoneyPacketHook {
XposedBridge.log(TAG + " auto-detail newRequest failed id=" + activityId);
return false;
}
fillLoginOnRequest(req, cl);
}
cacheLoginFromRequestObject(req);
fillLoginOnRequest(req, cl);
prepareRpcRequest(req);
XposedBridge.log(TAG + " auto-detail req id=" + activityId
+ " userId=" + stringField(req, "userId", "getUserId")
+ " session=" + abbreviate(stringField(req, "sessionId", "getSessionId"))
+ " loginId=" + abbreviate(stringField(req, "loginId", "getLoginId"))
+ " page=" + stringField(req, "page", "getPage")
+ " max=" + stringField(req, "maxResult", "getMaxResult"));
if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))
&& TextUtils.isEmpty(sCachedSessionId)) {
if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
XposedBridge.log(TAG + " auto-detail skip: no sessionId id=" + activityId
+ " (先手动点开任意一个红包详情一次以缓存模板)");
+ " (ILoginStorage 尚未就绪,等登录后 Activity resume 会重试)");
return false;
}
if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))
&& !TextUtils.isEmpty(sCachedSessionId)) {
fillLoginOnRequest(req, cl);
}
Object result = XposedHelpers.callMethod(task, "moneyPacketDetail", req);
if (result == null) {
XposedBridge.log(TAG + " auto-detail null result id=" + activityId);
@@ -605,6 +755,7 @@ public final class TngMoneyPacketHook {
return false;
}
MmpSnapshot probe = parseSnapshot(json);
enrichSnapshotFromJavaResult(result, probe);
if (probe == null || probe.claims.isEmpty()) {
String snippet = json.length() > 240 ? json.substring(0, 240) + "..." : json;
XposedBridge.log(TAG + " auto-detail empty claims id=" + activityId
@@ -612,9 +763,10 @@ public final class TngMoneyPacketHook {
+ " userId=" + stringField(req, "userId", "getUserId"));
return false;
}
forwardParsedJson(json, "auto:" + activityId, "rpc");
forwardSnapshot(probe, "auto:" + activityId, "rpc");
XposedBridge.log(TAG + " auto-detail ok activityId=" + activityId
+ " claims=" + probe.claims.size());
+ " claims=" + probe.claims.size()
+ " issued=" + probe.issueTime);
return true;
} catch (Throwable t) {
XposedBridge.log(TAG + " auto-detail fail id=" + activityId + ": " + t.getMessage());
@@ -653,7 +805,20 @@ public final class TngMoneyPacketHook {
}
private static void fillLoginOnRequest(Object req, ClassLoader cl) {
// 1) 优先用历史请求里缓存的登录态
ensureRpcTaskAndLogin(cl);
// 0) 从最近真实请求拷贝登录相关字段(历史页打开后即可,无需点详情)
Object donor = sLoginDonorRequest;
if (donor == null) {
donor = sTemplateHistoryRequest;
}
if (donor == null) {
donor = sTemplateDetailRequest;
}
if (donor != null && donor != req) {
copyLoginFields(donor, req);
cacheLoginFromRequestObject(donor);
}
// 1) 再用缓存的登录态覆盖
trySet(req, "setUserId", sCachedUserId);
trySet(req, "setSessionId", sCachedSessionId);
trySet(req, "setLoginId", sCachedLoginId);
@@ -673,37 +838,60 @@ public final class TngMoneyPacketHook {
&& !TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
return;
}
// 2) Hilt EntryPoint 兜底
// 2) 从 Repository.loginStorage / ILoginStorage 再读一遍
Object loginStorage = obtainLoginStorage(cl);
if (loginStorage == null) {
return;
}
applyLoginStorage(loginStorage);
trySet(req, "setUserId", sCachedUserId);
trySet(req, "setSessionId", sCachedSessionId);
trySet(req, "setLoginId", sCachedLoginId);
try {
Object repo = obtainMoneyPacketRepository(cl);
if (repo == null) {
return;
if (!TextUtils.isEmpty(sCachedUserId)) {
XposedHelpers.setObjectField(req, "userId", sCachedUserId);
}
Object loginStorage = null;
if (!TextUtils.isEmpty(sCachedSessionId)) {
XposedHelpers.setObjectField(req, "sessionId", sCachedSessionId);
}
if (!TextUtils.isEmpty(sCachedLoginId)) {
XposedHelpers.setObjectField(req, "loginId", sCachedLoginId);
}
} catch (Throwable ignored) {
}
}
private static void copyLoginFields(Object src, Object dst) {
if (src == null || dst == null) {
return;
}
String[] keys = {"userId", "sessionId", "loginId", "securityId", "apiName"};
for (String key : keys) {
try {
loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
Object v = null;
try {
v = XposedHelpers.getObjectField(src, key);
} catch (Throwable ignored) {
}
if (v == null) {
continue;
}
try {
XposedHelpers.setObjectField(dst, key, v);
} catch (Throwable ignored) {
}
String setter = "set" + Character.toUpperCase(key.charAt(0)) + key.substring(1);
trySet(dst, setter, v);
} catch (Throwable ignored) {
}
if (loginStorage == null) {
return;
}
// extParams 等 Map 也尽量带上
try {
Object ext = XposedHelpers.getObjectField(src, "extParams");
if (ext != null) {
XposedHelpers.setObjectField(dst, "extParams", ext);
}
Object accountId = XposedHelpers.callMethod(loginStorage, "getAccountId");
Object sessionId = XposedHelpers.callMethod(loginStorage, "getSessionId");
Object loginId = XposedHelpers.callMethod(loginStorage, "getLoginId");
trySet(req, "setUserId", accountId);
trySet(req, "setSessionId", sessionId);
trySet(req, "setLoginId", loginId);
if (accountId != null) {
sCachedUserId = String.valueOf(accountId);
}
if (sessionId != null) {
sCachedSessionId = String.valueOf(sessionId);
}
if (loginId != null) {
sCachedLoginId = String.valueOf(loginId);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " fillLogin skip: " + t.getMessage());
} catch (Throwable ignored) {
}
}
@@ -717,30 +905,344 @@ public final class TngMoneyPacketHook {
}
}
private static Object obtainMoneyPacketRepository(ClassLoader cl) {
/** 被动缓存App 任何地方读 session 时同步到本 Hook */
private static void hookLoginStorage(XC_LoadPackage.LoadPackageParam lpparam) {
// 不能 hook 接口抽象方法;只挂实现类
try {
Class<?> impl = XposedHelpers.findClass(
"my.com.tngdigital.user.model.CacheLoginStorageImpl", lpparam.classLoader);
XC_MethodHook cacheHook = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object v = param.getResult();
if (v == null || TextUtils.isEmpty(String.valueOf(v))) {
return;
}
String name = param.method.getName();
if ("getSessionId".equals(name)) {
sCachedSessionId = String.valueOf(v);
} else if ("getAccountId".equals(name)) {
sCachedUserId = String.valueOf(v);
} else if ("getLoginId".equals(name)) {
sCachedLoginId = String.valueOf(v);
}
}
};
for (String m : new String[]{"getSessionId", "getAccountId", "getLoginId"}) {
XposedBridge.hookAllMethods(impl, m, cacheHook);
}
XposedBridge.log(TAG + " hooked CacheLoginStorageImpl getters");
} catch (Throwable t) {
XposedBridge.log(TAG + " CacheLoginStorageImpl hook skip: " + t.getMessage());
}
}
/** 前台 Activity 恢复时自动拉历史列表(再级联详情),无需手动进红包历史页 */
private static void hookActivityResumeForAutoHistory(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
"android.app.Activity",
lpparam.classLoader,
"onResume",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
try {
Object act = param.thisObject;
if (act == null) {
return;
}
String pkg = String.valueOf(
XposedHelpers.callMethod(act, "getPackageName"));
if (!PACKAGE.equals(pkg)) {
return;
}
scheduleAutoHistory("activity-resume");
} catch (Throwable ignored) {
}
}
});
XposedBridge.log(TAG + " hooked Activity.onResume for auto history");
} catch (Throwable t) {
XposedBridge.log(TAG + " Activity.onResume hook skip: " + t.getMessage());
}
}
private static void scheduleAutoHistory(String reason) {
long now = System.currentTimeMillis();
if (sAutoHistoryRunning) {
return;
}
if (now - sLastAutoHistoryAt < AUTO_HISTORY_COOLDOWN_MS) {
return;
}
sAutoHistoryRunning = true;
final String why = reason;
AUTO_DETAIL_EXEC.execute(() -> {
try {
// 等登录/Hilt/Network 就绪;不要在主线程阻塞等 RPC
Thread.sleep(why != null && why.startsWith("repo") ? 1500L : 800L);
boolean ok = fetchHistoryListAuto(why);
if (ok) {
sLastAutoHistoryAt = System.currentTimeMillis();
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
} finally {
sAutoHistoryRunning = false;
}
});
}
/** 主动调 moneyPacketHistoryList结果走现有 scheduleAutoDetailsFromHistory */
private static boolean fetchHistoryListAuto(String reason) {
ClassLoader cl = sAppClassLoader;
ensureRpcTaskAndLogin(cl);
Object task = sMoneyPacketRpcTask;
if (cl == null || task == null) {
XposedBridge.log(TAG + " auto-history skip: no rpc task (" + reason + ")");
return false;
}
if (TextUtils.isEmpty(sCachedSessionId)) {
Object ls = obtainLoginStorage(cl);
if (ls != null) {
applyLoginStorage(ls);
}
}
if (TextUtils.isEmpty(sCachedSessionId)) {
XposedBridge.log(TAG + " auto-history skip: no sessionId yet (" + reason + ")");
return false;
}
try {
Class<?> reqCls = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketHistoryListRequest",
cl);
// 1) 已有官方请求模板(首次打开历史页后会缓存)→ 静默复用
if (sTemplateHistoryRequest != null
&& reqCls.isInstance(sTemplateHistoryRequest)) {
Object req = cloneRequestShallow(sTemplateHistoryRequest);
XposedBridge.log(TAG + " auto-history using template (" + reason + ")");
fillLoginOnRequest(req, cl);
prepareRpcRequest(req);
AUTO_HISTORY_SELF.set(Boolean.TRUE);
Object result;
try {
result = callHistoryListRpc(task, req);
} finally {
AUTO_HISTORY_SELF.remove();
}
if (result != null && historyHasJobs(result)) {
XposedBridge.log(TAG + " auto-history ok(template) → schedule details");
scheduleAutoDetailsFromHistory(result);
return true;
}
}
// 2) 无模板时:自造请求易「非法参数」,直接拉起官方历史页让 App 发正确 RPC
XposedBridge.log(TAG + " auto-history no template → open HistoryActivity (" + reason + ")");
openMoneyPacketHistoryActivity();
return false;
} catch (Throwable t) {
XposedBridge.log(TAG + " auto-history fail: " + t.getMessage());
return false;
}
}
private static boolean historyHasJobs(Object historyResult) {
return !extractHistoryJobs(historyResult).isEmpty();
}
/** 拉起官方历史页App 自己的 moneyPacketHistoryList 成功后 Hook 会缓存模板并级联拉详情 */
private static void openMoneyPacketHistoryActivity() {
try {
Context ctx = getContext();
if (ctx == null) {
return null;
return;
}
Class<?> entryPoint = Class.forName(
"my.com.tngdigital.funding.moneypacket.common.di.MoneyPacketEntryPoint",
false, cl);
Class<?> entryPoints = Class.forName("dagger.hilt.EntryPoints", false, cl);
Object ep = XposedHelpers.callStaticMethod(
entryPoints, "get", ctx.getApplicationContext(), entryPoint);
try {
return XposedHelpers.callMethod(ep, "repository");
} catch (Throwable ignored) {
}
for (Method m : ep.getClass().getMethods()) {
if (m.getParameterTypes().length == 0
&& m.getReturnType().getName().contains("MoneyPacketRepository")) {
return m.invoke(ep);
android.content.Intent intent = new android.content.Intent();
intent.setClassName(PACKAGE,
"my.com.tngdigital.funding.moneypacket.create.ui.MoneyPacketHistoryActivity");
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK
| android.content.Intent.FLAG_ACTIVITY_NO_ANIMATION);
ctx.startActivity(intent);
XposedBridge.log(TAG + " started MoneyPacketHistoryActivity");
} catch (Throwable t) {
XposedBridge.log(TAG + " start HistoryActivity fail: " + t.getMessage());
}
}
private static Object callHistoryListRpc(Object task, Object req) {
try {
return XposedHelpers.callMethod(task, "moneyPacketHistoryList", req);
} catch (Throwable t) {
XposedBridge.log(TAG + " history rpc fail: " + t.getMessage());
return null;
}
}
private static void ensureRpcTaskAndLogin(ClassLoader cl) {
if (cl == null) {
return;
}
if (sMoneyPacketRpcTask == null || TextUtils.isEmpty(sCachedSessionId)) {
Object repo = obtainMoneyPacketRepository(cl);
if (repo != null) {
if (sMoneyPacketRpcTask == null) {
try {
Object task = XposedHelpers.getObjectField(repo, "task");
if (task != null) {
sMoneyPacketRpcTask = task;
XposedBridge.log(TAG + " cached RPC task from repository");
}
} catch (Throwable ignored) {
}
}
try {
Object loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
applyLoginStorage(loginStorage);
} catch (Throwable ignored) {
}
}
}
if (TextUtils.isEmpty(sCachedSessionId)) {
applyLoginStorage(obtainLoginStorage(cl));
}
}
private static void applyLoginStorage(Object loginStorage) {
if (loginStorage == null) {
return;
}
try {
Object accountId = XposedHelpers.callMethod(loginStorage, "getAccountId");
Object sessionId = XposedHelpers.callMethod(loginStorage, "getSessionId");
Object loginId = XposedHelpers.callMethod(loginStorage, "getLoginId");
if (accountId != null && !TextUtils.isEmpty(String.valueOf(accountId))) {
sCachedUserId = String.valueOf(accountId);
}
if (sessionId != null && !TextUtils.isEmpty(String.valueOf(sessionId))) {
sCachedSessionId = String.valueOf(sessionId);
}
if (loginId != null && !TextUtils.isEmpty(String.valueOf(loginId))) {
sCachedLoginId = String.valueOf(loginId);
}
if (!TextUtils.isEmpty(sCachedSessionId)) {
XposedBridge.log(TAG + " login from storage session="
+ abbreviate(sCachedSessionId)
+ " userId=" + sCachedUserId);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " obtain repo fail: " + t.getMessage());
XposedBridge.log(TAG + " applyLoginStorage skip: " + t.getMessage());
}
}
private static Object obtainLoginStorage(ClassLoader cl) {
try {
Object repo = obtainMoneyPacketRepository(cl);
if (repo != null) {
try {
Object ls = XposedHelpers.getObjectField(repo, "loginStorage");
if (ls != null) {
return ls;
}
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
}
// Hilt尝试常见 EntryPoint 上返回 ILoginStorage 的无参方法
try {
Context ctx = getContext();
if (ctx == null || cl == null) {
return null;
}
String[] entryPoints = {
"my.com.tngdigital.member.di.MemberEntryPoint",
"my.com.tngdigital.user.di.UserEntryPoint",
"my.com.tngdigital.common.di.CommonEntryPoint",
};
Class<?> entryPointsCls = Class.forName("dagger.hilt.EntryPoints", false, cl);
for (String epName : entryPoints) {
try {
Class<?> epCls = Class.forName(epName, false, cl);
Object ep = XposedHelpers.callStaticMethod(
entryPointsCls, "get", ctx.getApplicationContext(), epCls);
for (Method m : ep.getClass().getMethods()) {
if (m.getParameterTypes().length != 0) {
continue;
}
if (m.getReturnType().getName().contains("ILoginStorage")) {
Object ls = m.invoke(ep);
if (ls != null) {
return ls;
}
}
}
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
}
return null;
}
private static Object obtainMoneyPacketRepository(ClassLoader cl) {
Context ctx = getContext();
if (ctx == null || cl == null) {
return null;
}
Context appCtx = ctx.getApplicationContext() != null ? ctx.getApplicationContext() : ctx;
Class<?> entryPoint;
try {
entryPoint = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.di.MoneyPacketEntryPoint", cl);
} catch (Throwable t) {
XposedBridge.log(TAG + " MoneyPacketEntryPoint missing: " + t.getMessage());
return null;
}
// 1) EntryPointAccessors.fromApplicationHilt Android 推荐)
try {
Class<?> accessors = XposedHelpers.findClass(
"dagger.hilt.android.EntryPointAccessors", cl);
Object ep = XposedHelpers.callStaticMethod(
accessors, "fromApplication", appCtx, entryPoint);
Object repo = invokeRepository(ep);
if (repo != null) {
return repo;
}
} catch (Throwable t) {
XposedBridge.log(TAG + " EntryPointAccessors fail: " + t.getMessage());
}
// 2) EntryPoints.get
try {
Class<?> entryPoints = XposedHelpers.findClass("dagger.hilt.EntryPoints", cl);
Object ep = XposedHelpers.callStaticMethod(
entryPoints, "get", appCtx, entryPoint);
Object repo = invokeRepository(ep);
if (repo != null) {
return repo;
}
} catch (Throwable t) {
XposedBridge.log(TAG + " EntryPoints.get fail: " + t.getMessage());
}
return null;
}
private static Object invokeRepository(Object ep) {
if (ep == null) {
return null;
}
try {
return XposedHelpers.callMethod(ep, "repository");
} catch (Throwable ignored) {
}
for (Method m : ep.getClass().getMethods()) {
if (m.getParameterTypes().length == 0
&& m.getReturnType().getName().contains("MoneyPacketRepository")) {
try {
return m.invoke(ep);
} catch (Throwable ignored) {
}
}
}
return null;
}
@@ -783,6 +1285,15 @@ public final class TngMoneyPacketHook {
if (snapshot == null || snapshot.claims.isEmpty()) {
return;
}
forwardSnapshot(snapshot, sourceHint, channel);
}
private static void forwardSnapshot(MmpSnapshot snapshot, String sourceHint, String channel) {
if (snapshot == null || snapshot.claims.isEmpty()) {
return;
}
dedupeClaims(snapshot);
snapshot.applyCachedIssueTime();
if (!shouldForwardPacket(snapshot)) {
return;
}
@@ -799,7 +1310,158 @@ public final class TngMoneyPacketHook {
String content = snapshot.formatForForward(channel, sourceHint);
HookForwarder.forward(context, PACKAGE, title, content, HookBridge.SOURCE_XPOSED_TNG_MMP);
XposedBridge.log(TAG + " captured activityId=" + snapshot.packetId
+ " claims=" + snapshot.claims.size() + " via " + channel);
+ " claims=" + snapshot.claims.size()
+ " issued=" + snapshot.issueTime
+ " via " + channel);
}
/** 直接从 Java 结果对象读 gmtModified / createTime不依赖 JSON 字段是否序列化成功 */
private static void enrichSnapshotFromJavaResult(Object result, MmpSnapshot snapshot) {
if (result == null || snapshot == null) {
return;
}
try {
Object pools = null;
try {
pools = XposedHelpers.callMethod(result, "getActivityPoolInfos");
} catch (Throwable ignored) {
}
if (pools == null) {
try {
pools = XposedHelpers.getObjectField(result, "activityPoolInfos");
} catch (Throwable ignored) {
}
}
if (pools instanceof Iterable) {
for (Object info : (Iterable<?>) pools) {
if (info == null) {
continue;
}
String name = stringField(info, "receiverName", "getReceiverName");
String claimTime = firstNonEmpty(
stringField(info, "gmtModified", "getGmtModified"),
stringField(info, "gmtCreate", "getGmtCreate"),
stringField(info, "createTime", "getCreateTime"));
String amount = null;
try {
Object money = XposedHelpers.callMethod(info, "getAmount");
if (money != null) {
amount = normalizeMoneyText(String.valueOf(
XposedHelpers.callMethod(money, "getAmount")));
}
} catch (Throwable ignored) {
}
if (TextUtils.isEmpty(amount)) {
amount = normalizeMoneyText(stringField(info, "amount", "getAmount"));
}
if (TextUtils.isEmpty(name)) {
continue;
}
boolean matched = false;
for (ClaimLine line : snapshot.claims) {
if (name.equals(line.nickname)) {
if (TextUtils.isEmpty(line.claimTime) && !TextUtils.isEmpty(claimTime)) {
line.claimTime = claimTime;
}
matched = true;
}
}
if (!matched && !TextUtils.isEmpty(amount)) {
ClaimLine line = new ClaimLine();
line.nickname = name;
line.amount = amount;
line.claimTime = claimTime;
snapshot.claims.add(line);
}
}
}
Object summary = null;
try {
summary = XposedHelpers.callMethod(result, "getSummaryInfo");
} catch (Throwable ignored) {
}
if (summary == null) {
try {
summary = XposedHelpers.getObjectField(result, "summaryInfo");
} catch (Throwable ignored) {
}
}
if (summary != null) {
if (TextUtils.isEmpty(snapshot.senderName)) {
snapshot.senderName = stringField(summary, "senderName", "getSenderName");
}
if (TextUtils.isEmpty(snapshot.packetId)) {
snapshot.packetId = stringField(summary, "activityId", "getActivityId");
}
if (TextUtils.isEmpty(snapshot.totalAmount)) {
try {
Object money = XposedHelpers.callMethod(summary, "getTotalAmount");
if (money != null) {
snapshot.totalAmount = normalizeMoneyText(String.valueOf(
XposedHelpers.callMethod(money, "getAmount")));
}
} catch (Throwable ignored) {
}
}
}
snapshot.applyCachedIssueTime();
} catch (Throwable t) {
XposedBridge.log(TAG + " enrich times failed: " + t.getMessage());
}
}
private static void cacheIssueTimesFromHistory(Object historyResult) {
if (historyResult == null) {
return;
}
try {
for (String[] job : extractHistoryJobs(historyResult)) {
if (job.length > 2 && looksLikeActivityId(job[0]) && !TextUtils.isEmpty(job[2])) {
synchronized (ACTIVITY_ISSUE_TIME) {
ACTIVITY_ISSUE_TIME.put(job[0], job[2]);
}
}
}
XposedBridge.log(TAG + " cached issue times size=" + ACTIVITY_ISSUE_TIME.size());
} catch (Throwable t) {
XposedBridge.log(TAG + " cache issue times failed: " + t.getMessage());
}
}
private static void dedupeClaims(MmpSnapshot snapshot) {
if (snapshot == null || snapshot.claims.size() <= 1) {
return;
}
java.util.LinkedHashMap<String, ClaimLine> map = new java.util.LinkedHashMap<>();
for (ClaimLine line : snapshot.claims) {
if (line == null || TextUtils.isEmpty(line.nickname)) {
continue;
}
ClaimLine old = map.get(line.nickname);
if (old == null) {
map.put(line.nickname, line);
continue;
}
// 同昵称保留金额更大的;时间有则补上
try {
double a = Double.parseDouble(old.amount);
double b = Double.parseDouble(line.amount);
if (b > a) {
if (TextUtils.isEmpty(line.claimTime)) {
line.claimTime = old.claimTime;
}
map.put(line.nickname, line);
} else if (TextUtils.isEmpty(old.claimTime) && !TextUtils.isEmpty(line.claimTime)) {
old.claimTime = line.claimTime;
}
} catch (Throwable ignored) {
if (TextUtils.isEmpty(old.claimTime) && !TextUtils.isEmpty(line.claimTime)) {
old.claimTime = line.claimTime;
}
}
}
snapshot.claims.clear();
snapshot.claims.addAll(map.values());
}
private static MmpSnapshot parseSnapshot(String json) {
@@ -905,6 +1567,8 @@ public final class TngMoneyPacketHook {
putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName", "creatorName");
putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "packetTotalAmount",
"totalPacketAmount", "originalAmount");
putIfPresent(obj, snapshot, "createTime", "createdTime", "gmtCreate", "issueTime",
"issuedTime", "sendTime", "packetCreateTime");
if (TextUtils.isEmpty(snapshot.title)) {
String merchant = firstNonEmpty(
obj.optString("merchantName", null),
@@ -925,7 +1589,13 @@ public final class TngMoneyPacketHook {
continue;
}
String keyLower = key.toLowerCase(Locale.US);
if (keyLower.equals("activityid") || keyLower.contains("packet")
if (keyLower.contains("createtime") || keyLower.contains("created")
|| keyLower.contains("gmtcreate") || keyLower.contains("issue")
|| keyLower.contains("sendtime")) {
if (TextUtils.isEmpty(snapshot.issueTime) && !looksLikeMoneyJson(val)) {
snapshot.issueTime = val;
}
} else if (keyLower.equals("activityid") || keyLower.contains("packet")
|| keyLower.contains("mmp") || keyLower.contains("order")
|| keyLower.contains("biz") || keyLower.equals("packetcode")) {
// 已有 UUID 形态的 activityId 时不覆盖
@@ -1003,7 +1673,10 @@ public final class TngMoneyPacketHook {
readFieldText(obj, "claimedTime"),
readFieldText(obj, "receiveTime"),
readFieldText(obj, "gmtClaim"),
readFieldText(obj, "gmtModified"),
readFieldText(obj, "gmtModify"),
readFieldText(obj, "gmtCreate"),
readFieldText(obj, "createTime"),
readFieldText(obj, "time"));
return line;
}
@@ -1287,6 +1960,8 @@ public final class TngMoneyPacketHook {
String title;
String senderName;
String totalAmount;
/** 发放时间(历史 createTime 或详情里的 createTime */
String issueTime;
final List<ClaimLine> claims = new ArrayList<>();
String dedupKey() {
@@ -1304,7 +1979,31 @@ public final class TngMoneyPacketHook {
return sb.toString();
}
void applyCachedIssueTime() {
if (!TextUtils.isEmpty(issueTime) || TextUtils.isEmpty(packetId)) {
return;
}
synchronized (ACTIVITY_ISSUE_TIME) {
String cached = ACTIVITY_ISSUE_TIME.get(packetId);
if (!TextUtils.isEmpty(cached)) {
issueTime = cached;
}
}
}
String formatForForward(String channel, String sourceHint) {
applyCachedIssueTime();
// 详情无发放时间时,用最早领取时间兜底(仍优于拉取时间)
if (TextUtils.isEmpty(issueTime)) {
for (ClaimLine line : claims) {
if (!TextUtils.isEmpty(line.claimTime)) {
if (TextUtils.isEmpty(issueTime)
|| line.claimTime.compareTo(issueTime) < 0) {
issueTime = line.claimTime;
}
}
}
}
StringBuilder sb = new StringBuilder();
sb.append("[MMP统计]");
if (!TextUtils.isEmpty(packetId)) {
@@ -1319,6 +2018,9 @@ public final class TngMoneyPacketHook {
if (!TextUtils.isEmpty(totalAmount)) {
sb.append(" | total=").append(sanitizeMeta(totalAmount));
}
if (!TextUtils.isEmpty(issueTime)) {
sb.append(" | issued=").append(sanitizeMeta(issueTime));
}
sb.append(" | via=").append(sanitizeMeta(channel));
if (!TextUtils.isEmpty(sourceHint)) {
String src = sourceHint.length() > 120