227 lines
7.6 KiB
Go
227 lines
7.6 KiB
Go
// 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
|
||
}
|