feat(mmp): 领取人ID/模块版本检测,并修正状态条误报红

展示 receiverId 与 activityPoolId;检测 Hook 是否最新;按真实时间排序;有心跳与定时拉取时不再因久无新数据爆红。
This commit is contained in:
mars
2026-08-05 15:09:51 +08:00
parent a4aed01b29
commit 293979122d
24 changed files with 1641 additions and 186 deletions

View File

@@ -16,10 +16,23 @@ PORT = 8765
MAX_MESSAGES = 500
SETTINGS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_settings.json")
PACKETS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_packets.json")
HOOK_STATUS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_hook_status.json")
# 与 xposed-module / App MmpModuleInfo 同步
EXPECTED_MODULE_CODE = 3
EXPECTED_MODULE_NAME = "1.2.0"
_messages = deque(maxlen=MAX_MESSAGES)
_mmp_messages = deque(maxlen=MAX_MESSAGES)
_mmp_synced = {} # packetId -> packet dict手机全量同步落盘
_mmp_hook_status = {
"liveCode": 0,
"liveName": "",
"liveAt": 0, # epoch ms
"installedCode": 0,
"installedName": "",
"source": "",
}
_lock = threading.Lock()
_last_dedup = {"key": None, "ts": 0.0}
_mmp_last_dedup = {"key": None, "ts": 0.0}
@@ -31,6 +44,7 @@ _DEFAULT_MMP_SETTINGS = {
"detailGapMs": 80,
"pagePollMs": 1000,
"openHistoryIfNoTemplate": True,
"autoBounceHistoryOnDisconnect": True,
}
_mmp_settings = dict(_DEFAULT_MMP_SETTINGS)
@@ -69,6 +83,8 @@ def _normalize_mmp_settings(raw):
out["pagePollMs"] = max(500, min(30000, int(raw.get("pagePollMs", out["pagePollMs"]))))
out["openHistoryIfNoTemplate"] = bool(raw.get(
"openHistoryIfNoTemplate", out["openHistoryIfNoTemplate"]))
out["autoBounceHistoryOnDisconnect"] = bool(raw.get(
"autoBounceHistoryOnDisconnect", out["autoBounceHistoryOnDisconnect"]))
except (TypeError, ValueError):
pass
return out
@@ -114,6 +130,7 @@ def _add_message(payload):
])
now = time.time()
is_mmp = _is_mmp_message(item)
content_for_mod = ""
with _lock:
if is_mmp:
if (_mmp_last_dedup["key"] == dedup_key
@@ -125,6 +142,7 @@ def _add_message(payload):
_mmp_messages.appendleft(item)
item["id"] = len(_mmp_messages)
channel = "MMP"
content_for_mod = item.get("content") or ""
else:
if (_last_dedup["key"] == dedup_key
and now - float(_last_dedup["ts"]) < 3.0):
@@ -135,6 +153,8 @@ def _add_message(payload):
_messages.appendleft(item)
item["id"] = len(_messages)
channel = "MSG"
if content_for_mod:
_note_module_from_content(content_for_mod)
print("[{0}] [{1}] [{2}] [{3}] {4} | {5}".format(
_now_iso(),
channel,
@@ -287,17 +307,40 @@ def _parse_mmp_content(content):
left, right = line.split("->", 1)
nick = left.strip()
right = right.strip()
user_id = ""
pool_id = ""
if " #rid=" in right:
before, _, rest = right.partition(" #rid=")
right = before.strip()
rid_part = rest.strip()
if " #pool=" in rid_part:
user_id, _, pool_rest = rid_part.partition(" #pool=")
user_id = user_id.strip()
pool_id = pool_rest.strip()
elif " " in rid_part:
user_id = rid_part.split(" ", 1)[0].strip()
else:
user_id = rid_part
if "#pool=" in user_id:
user_id, _, pool_id = user_id.partition("#pool=")
user_id = user_id.strip()
pool_id = pool_id.strip()
if " #pool=" in right and not pool_id:
before, _, pool_rest = right.partition(" #pool=")
right = before.strip()
pool_id = pool_rest.strip()
claim_time = ""
if right.endswith(")") and "(" in right and not right.startswith("{"):
amount_raw = right
if "(" in right and ")" in right and not right.startswith("{"):
amt, _, rest = right.partition("(")
amount_raw = amt.strip()
claim_time = rest.rstrip(")").strip()
else:
amount_raw = right
claim_time = rest.split(")", 1)[0].strip()
amount_num = _normalize_money(amount_raw)
if nick:
if nick or user_id:
claims.append({
"nickname": nick,
"nickname": nick or "?",
"userId": user_id,
"poolId": pool_id,
"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,
@@ -308,40 +351,61 @@ def _parse_mmp_content(content):
def _claims_fingerprint(claims):
rows = []
for c in claims:
rows.append("{0}={1}".format(c.get("nickname") or "?", c.get("amount") or "0"))
key = c.get("userId") or c.get("nickname") or "?"
rows.append("{0}={1}".format(key, c.get("amount") or "0"))
rows.sort()
return "|".join(rows)
def _aggregate_claims(claims):
"""同一快照内同昵称只保留一笔(取较大金额),红包每人只领一次,禁止累加导致翻倍。"""
"""同一快照内按 userId无则昵称去重取较大金额禁止同人累加翻倍。"""
buckets = {}
order = []
for c in claims:
nick = c.get("nickname") or "?"
uid = (c.get("userId") or "").strip()
key = ("id:" + uid) if uid else ("n:" + nick)
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": amt, "count": 1,
"claimTime": c.get("claimTime") or ""}
order.append(nick)
if key not in buckets:
buckets[key] = {
"nickname": nick,
"userId": uid,
"poolId": (c.get("poolId") or "").strip(),
"amount": amt,
"count": 1,
"claimTime": c.get("claimTime") or "",
}
order.append(key)
continue
b = buckets[nick]
b = buckets[key]
b["count"] += 1
if uid and not b.get("userId"):
b["userId"] = uid
if c.get("poolId") and not b.get("poolId"):
b["poolId"] = c.get("poolId") or ""
if amt >= b["amount"]:
b["amount"] = amt
if nick and nick != "?":
b["nickname"] = nick
if c.get("claimTime"):
b["claimTime"] = c.get("claimTime") or b["claimTime"]
if uid:
b["userId"] = uid
if c.get("poolId"):
b["poolId"] = c.get("poolId") or b.get("poolId") or ""
elif c.get("claimTime") and not b["claimTime"]:
b["claimTime"] = c.get("claimTime") or ""
result = []
for nick in order:
b = buckets[nick]
for key in order:
b = buckets[key]
result.append({
"nickname": nick,
"nickname": b["nickname"],
"userId": b.get("userId") or "",
"poolId": b.get("poolId") or "",
"amount": round(b["amount"], 4),
"amountText": "{0:.2f}".format(b["amount"]),
"claimCount": b["count"],
@@ -353,6 +417,159 @@ def _aggregate_claims(claims):
return result
def _parse_mod_token(mod):
"""mod=1.2.0/3 → (name, code)"""
text = (mod or "").strip()
if not text:
return "", 0
name = text
code = 0
if "/" in text:
name, _, code_part = text.rpartition("/")
name = name.strip()
try:
code = int(code_part.strip())
except (TypeError, ValueError):
code = 0
return name, code
def _note_module_from_content(content):
if not content or "[MMP统计]" not in content:
return
head = content.strip().split("\n", 1)[0]
if "mod=" not in head:
return
raw = head.split("mod=", 1)[1]
for stop in (" | ", " src=", " "):
if stop in raw:
raw = raw.split(stop, 1)[0]
break
name, code = _parse_mod_token(raw)
_update_hook_status({
"liveCode": code,
"liveName": name,
"liveAt": int(time.time() * 1000),
"source": "message",
})
def _load_hook_status():
global _mmp_hook_status
try:
if not os.path.isfile(HOOK_STATUS_PATH):
return
with open(HOOK_STATUS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
with _lock:
_mmp_hook_status.update({
"liveCode": int(data.get("liveCode") or 0),
"liveName": str(data.get("liveName") or ""),
"liveAt": int(data.get("liveAt") or 0),
"installedCode": int(data.get("installedCode") or 0),
"installedName": str(data.get("installedName") or ""),
"source": str(data.get("source") or ""),
})
except Exception as e:
print("load mmp hook status failed:", e)
def _save_hook_status():
try:
with _lock:
data = dict(_mmp_hook_status)
with open(HOOK_STATUS_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
print("save mmp hook status failed:", e)
def _update_hook_status(patch):
if not isinstance(patch, dict):
return
with _lock:
try:
incoming_live_at = int(patch.get("liveAt") or 0)
except (TypeError, ValueError):
incoming_live_at = 0
stale_live = (incoming_live_at > 0
and int(_mmp_hook_status.get("liveAt") or 0) > 0
and incoming_live_at < int(_mmp_hook_status.get("liveAt") or 0))
for k in ("liveCode", "liveName", "liveAt", "installedCode", "installedName", "source"):
if k not in patch:
continue
if stale_live and k in ("liveCode", "liveName", "liveAt", "source"):
continue
# 无心跳时不要用 0 覆盖已有运行版本
if incoming_live_at <= 0 and k in ("liveCode", "liveName", "liveAt"):
continue
val = patch[k]
if k in ("liveCode", "liveAt", "installedCode"):
try:
val = int(val or 0)
except (TypeError, ValueError):
val = 0
else:
val = str(val or "")
if k in ("installedCode",) and val <= 0:
continue
if k in ("installedName",) and not val:
continue
_mmp_hook_status[k] = val
_save_hook_status()
def _describe_hook_status():
with _lock:
st = dict(_mmp_hook_status)
installed = int(st.get("installedCode") or 0)
installed_name = st.get("installedName") or ""
live_code = int(st.get("liveCode") or 0)
live_name = st.get("liveName") or ""
live_at = int(st.get("liveAt") or 0)
now_ms = int(time.time() * 1000)
if installed > 0:
apk = "模块APK" + (installed_name or str(installed))
if installed < EXPECTED_MODULE_CODE:
apk += "(旧,期望{0})".format(EXPECTED_MODULE_NAME)
else:
apk = "模块APK未知等手机同步"
age = (now_ms - live_at) if live_at > 0 else -1
if age < 0:
live = "运行中:未检测到(请强停再开 TNG"
ok = False
elif age > 3 * 60 * 1000:
live = "运行中:心跳偏旧({0}分钟前 {1})".format(
age // 60000, live_name or live_code or "?")
ok = False
elif live_code >= EXPECTED_MODULE_CODE:
live = "运行中:最新({0})".format(live_name or live_code)
ok = True
elif live_code > 0:
live = "运行中:旧版({0},期望{1})".format(
live_name or live_code, EXPECTED_MODULE_NAME)
ok = False
else:
live = "运行中:未知"
ok = False
return {
"ok": ok,
"text": apk + " · " + live,
"expectedCode": EXPECTED_MODULE_CODE,
"expectedName": EXPECTED_MODULE_NAME,
"liveCode": live_code,
"liveName": live_name,
"liveAt": live_at,
"installedCode": installed,
"installedName": installed_name,
"ageMs": age,
}
def _packet_score(it):
board = it.get("leaderboard") or it.get("claims") or []
@@ -387,6 +604,8 @@ def _normalize_synced_packet(raw):
nick = (row.get("nickname") or "?").strip() or "?"
norm_board.append({
"nickname": nick,
"userId": (row.get("userId") or "").strip(),
"poolId": (row.get("poolId") or "").strip(),
"amount": round(amt, 4),
"amountText": row.get("amountText") or "{0:.2f}".format(amt),
"claimTime": row.get("claimTime") or "",
@@ -471,6 +690,16 @@ def _sync_mmp_packets(payload):
items = []
if isinstance(payload, dict):
items = payload.get("packets") or []
hook = payload.get("hookStatus")
if isinstance(hook, dict):
_update_hook_status({
"liveCode": hook.get("liveCode") or hook.get("moduleVersionCode") or 0,
"liveName": hook.get("liveName") or hook.get("moduleVersionName") or "",
"liveAt": hook.get("liveAt") or hook.get("updatedAt") or int(time.time() * 1000),
"installedCode": hook.get("installedCode") or 0,
"installedName": hook.get("installedName") or "",
"source": "phone-sync",
})
elif isinstance(payload, list):
items = payload
if not isinstance(items, list):
@@ -806,6 +1035,9 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/mmp":
_json_response(self, 200, _mmp_packets())
return
if path == "/api/mmp/status":
_json_response(self, 200, _describe_hook_status())
return
if path == "/api/mmp/settings":
_json_response(self, 200, _get_mmp_settings())
return
@@ -889,6 +1121,7 @@ class Handler(BaseHTTPRequestHandler):
_load_mmp_synced()
_load_hook_status()
def main():