Files

227 lines
7.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package notifywatcher 通知栏快速通道监听(独立于 ledgermon 的账本慢通道)。
//
// 背景(2026-08-30 真机实测 + 用户需求确认):
// - 微信/支付宝收款到账会弹系统通知(dumpsys notification --noredact 可读全文);
// - 通知栏比账本页快 ~15s,是收款感知的快速通道;
// - 通知栏数据不清理会一直存在 → 匹配窗口必须以通知 when(收款发生时刻)为锚,
// 不能用 time.Now()(否则旧通知会错误匹配新订单);
// - 账户区分靠 uid:微信/支付宝主空间 uid 无 999 前缀,双开(XSpace) uid 有 999 前缀
// (实测:com.tencent.mm uid=10277 主号 / uid=99910277 双开;支付宝同理 10250/99910250)。
//
// 本包只负责:设备枚举 → 按设备轮询 dumpsys notification --noredact → 解析 →
// 指纹去重 → 回调 main.go 注入的匹配入账函数(复用 matchPersonalOrder 链路)。
// 不直接触碰 UI/账本页,与 ledgermon 的 DeviceActor 串行队列零竞争。
package notifywatcher
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// 收款通知金额提取正则(微信/支付宝实测格式):
// - 微信: "微信支付收款0.10元(朋友到店)" → 文本含 "收款0.10元"
// - 支付宝: "你已成功收款0.10元(老顾客消费)" → 标题含 "收款0.10元"
//
// 兼容 "收款 1 元"、"到账 12.5元" 等变体(贪心取收款/到账后第一个金额)。
var amountRe = regexp.MustCompile(`(?:收款|到账)\s*([0-9]+(?:\.[0-9]{1,2})?)元`)
// 目标包名:微信 / 支付宝(个人免签通道对应的收款 APP)。
const (
pkgWechat = "com.tencent.mm"
pkgAlipay = "com.eg.android.AlipayGphone"
)
// Event 单条收款通知的解析结果(设备级:属于哪台设备、哪个空间)。
type Event struct {
DeviceID string // 设备ID(personal.devices.device_id,枚举时携带)
Provider string // "wechat" / "alipay"
Space int // 0=主空间,999=双开(XSpace)
AmountMinor int64 // 金额(分)
When int64 // 通知创建时间(when= 毫秒时间戳,收款发生时刻)
Key string // 系统通知 key(0|pkg|id|tag|uid,同通知轮询去重基准之一)
Title string // 通知标题(审计)
Text string // 通知正文(审计)
Raw string // 指纹原文(审计;含设备ID保证跨设备隔离)
}
// Fingerprint 通知指纹:同设备+同通知(key)+同金额+同时刻 只处理一次。
// when 来自通知系统时间戳,同一条通知每次 dump 不变 → 幂等去重可靠。
func (e *Event) Fingerprint() string {
raw := fmt.Sprintf("notify:%s|%s|%d|%s|%d|%d", e.DeviceID, e.Provider, e.Space, e.Key, e.AmountMinor, e.When)
return sha256Hex(raw)
}
// Parse 解析 dumpsys notification --noredact 输出,返回该设备的所有收款通知。
// deviceID 由调用方传入(同一份输出只会属于一台设备)。
// 规则:
// 1. 只关心 pkg ∈ {微信, 支付宝} 的 NotificationRecord;
// 2. 从 user=UserHandle{0}/{999} 判定空间;
// 3. 从标题/正文提取金额(收款/到账 XX元),提取不到金额的跳过(非收款通知);
// 4. 同一条通知被 Android 聚合/折叠时可能产生多条记录,按 (key,when) 幂等去重。
func Parse(out string, deviceID string) []Event {
var events []Event
seen := make(map[string]struct{})
// 按 NotificationRecord( 分块解析:块内收集 pkg/user/key/title/text/when
for _, block := range splitRecords(out) {
pkg := fieldOf(block, "pkg=")
if pkg != pkgWechat && pkg != pkgAlipay {
continue
}
provider := "wechat"
if pkg == pkgAlipay {
provider = "alipay"
}
// 空间:user=UserHandle{999} → 双开;user=UserHandle{0} → 主空间
space := 0
if m := regexp.MustCompile(`user=UserHandle\{(\d+)\}`).FindStringSubmatch(block); len(m) == 2 {
if v, err := strconv.Atoi(m[1]); err == nil {
space = v
}
}
// 通知 key:NotificationRecord(0x...: pkg=... key=0|pkg|id|tag|uid)
key := fieldOf(block, "key=")
// when 毫秒时间戳
var when int64
if m := regexp.MustCompile(`when=(\d+)`).FindStringSubmatch(block); len(m) == 2 {
when, _ = strconv.ParseInt(m[1], 10, 64)
}
title, text := textsOf(block)
// 金额:优先标题(支付宝在标题,微信标题也可能含),再正文
amount := extractAmount(title)
if amount <= 0 {
amount = extractAmount(text)
}
if amount <= 0 {
continue // 非收款通知(无金额)或金额格式未知 → 不处理
}
if when <= 0 || key == "" {
continue // 缺时间戳/缺key 的畸形记录不处理
}
// 幂等:同一 (key,when) 只保留一条(聚合折叠场景)
uid := key + "|" + strconv.FormatInt(when, 10)
if _, ok := seen[uid]; ok {
continue
}
seen[uid] = struct{}{}
events = append(events, Event{
DeviceID: deviceID,
Provider: provider,
Space: space,
AmountMinor: amount,
When: when,
Key: key,
Title: title,
Text: text,
Raw: fmt.Sprintf("notify:%s|%s|%d|%s", deviceID, provider, space, key),
})
}
return events
}
// splitRecords 按 NotificationRecord( 拆分通知块(每块含完整通知字段)。
func splitRecords(out string) []string {
var blocks []string
cur := strings.Builder{}
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "NotificationRecord(") {
if cur.Len() > 0 {
blocks = append(blocks, cur.String())
}
cur.Reset()
}
cur.WriteString(line)
cur.WriteString("\n")
}
if cur.Len() > 0 {
blocks = append(blocks, cur.String())
}
return blocks
}
// fieldOf 提取形如 "name=value" 的字段值(value 到下一个空格/冒号为止)。
func fieldOf(block, prefix string) string {
i := strings.Index(block, prefix)
if i < 0 {
return ""
}
rest := block[i+len(prefix):]
end := strings.IndexAny(rest, " \n")
if end < 0 {
return rest
}
return rest[:end]
}
// textsOf 提取通知标题与正文(android.title / android.text 的 String(...) 内容)。
func textsOf(block string) (title, text string) {
title = quotedStringOf(block, "android.title=String (")
text = quotedStringOf(block, "android.text=String (")
// 标题缺失时用 tickerText 兜底(tickerText=微信收款助手: 微信支付收款0.10元)
if title == "" {
if t := quotedStringOf(block, "tickerText="); t != "" {
if i := strings.Index(t, ": "); i >= 0 {
title = t[:i]
if text == "" {
text = t[i+2:]
}
} else {
title = t
}
}
}
return title, text
}
// quotedStringOf 提取 "prefix<内容>)" 形式的值(内容到最近右括号)。
func quotedStringOf(block, prefix string) string {
i := strings.Index(block, prefix)
if i < 0 {
return ""
}
rest := block[i+len(prefix):]
j := strings.Index(rest, ")")
if j < 0 {
return rest
}
return rest[:j]
}
// extractAmount 从通知文本提取金额(分)。"收款0.10元" → 10。
func extractAmount(s string) int64 {
m := amountRe.FindStringSubmatch(s)
if len(m) != 2 {
return 0
}
return amountToMinor(m[1])
}
// amountToMinor 金额字符串转分("0.10" → 10,"1" → 100,"1.5" → 150)。
func amountToMinor(s string) int64 {
var major, minor int64
if _, err := fmt.Sscanf(s, "%d.%d", &major, &minor); err != nil {
// 无小数:整数元
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
return v * 100
}
return 0
}
if i := strings.IndexByte(s, '.'); i >= 0 {
frac := s[i+1:]
switch len(frac) {
case 1:
minor *= 10
case 2:
// 保持原样
default:
minor = 0
}
} else {
minor = 0
}
return major*100 + minor
}