feat: map transfer/hb status labels into export and chat cards

This commit is contained in:
wuyouMaster
2026-09-24 19:01:15 +08:00
parent 95b3ba6121
commit 88ee3f19bb
10 changed files with 555 additions and 12 deletions
+28 -4
View File
@@ -1172,13 +1172,37 @@ const renderExportScript = (name: string): string => `
}
const renderPaymentContent = (data, kind) => {
const isTransfer = kind === 'transfer'
const pay = (isTransfer ? data.transfer : data.pay) || {}
const amount = pay.amountText
const memo = pay.payMemo
const statusText = isTransfer ? pay.transferStatusText : pay.redPacketStatusText
const meta = [amount, memo ? '备注:' + memo : '', statusText]
.filter(Boolean)
.join(' · ')
const title = isTransfer
? (data.title || '微信转账')
: (pay.sendTitle || pay.receiveTitle || data.title || '微信红包')
const blurb =
data.description ||
data.des ||
(isTransfer ? '转账消息' : (pay.sceneText || '恭喜发财,大吉大利'))
// 状态/金额/备注始终单独露出,避免被消息原文 des 盖住。
const description = meta
? (blurb && blurb !== meta ? blurb + ' · ' + meta : meta)
: blurb
const footerBits = isTransfer
? [pay.paySubtype === '3' ? '收款' : pay.paySubtype === '1' || pay.paySubtype === '4' ? '转账' : '',
pay.transferId ? '单号 ' + pay.transferId : '']
: [pay.hbType ? '类型 ' + pay.hbType : '', pay.sendId ? 'sendid ' + pay.sendId : '']
return '<div class="structured-content payment-content ' + (isTransfer ? 'transfer' : 'red-packet') +
'" data-rich-kind="' + (isTransfer ? 'transfer' : 'redPacket') + '">' +
'<div class="structured-kicker">' + (isTransfer ? '微信转账' : '微信红包') + '</div>' +
'<div class="structured-title">' + displayText(data.title || (isTransfer ? '微信转账' : '微信红包')) + '</div>' +
'<div class="structured-description">' +
displayText(data.description || data.des || (isTransfer ? '转账消息' : '恭喜发财,大吉大利')) +
'</div></div>'
'<div class="structured-title">' + displayText(title) + '</div>' +
'<div class="structured-description">' + displayText(description) + '</div>' +
(footerBits.filter(Boolean).length
? '<div class="structured-footer">' + displayText(footerBits.filter(Boolean).join(' · ')) + '</div>'
: '') +
'</div>'
}
const renderVoipContent = (data) => {
const title = Number(data.roomType) === 1 ? '视频通话' : '语音通话'
+103 -5
View File
@@ -1,3 +1,5 @@
import { describeRedPacketStatus, describeTransferStatus } from '../shared/payment-status'
type TextContent = { type: 'text'; content: string }
type VoiceContent = { type: 'voice'; duration?: number }
type LocationContent = {
@@ -22,6 +24,40 @@ type ShareContent = {
appname?: string
typeVal?: string
articles?: ShareArticle[]
transfer?: TransferPaymentInfo
}
type TransferPaymentInfo = {
paySubtype?: string
amountText?: string
transcationId?: string
transferId?: string
invalidTime?: string
beginTransferTime?: string
effectiveDate?: string
payMemo?: string
receiverUsername?: string
payerUsername?: string
transferStatus?: string
/** `transfer_status` 展示文案(只读)。 */
transferStatusText?: string
}
type RedPacketPaymentInfo = {
templateId?: string
receiveTitle?: string
sendTitle?: string
sceneText?: string
senderDes?: string
receiverDes?: string
iconUrl?: string
nativeUrl?: string
sendId?: string
hbType?: string
hbStatus?: string
receiveStatus?: string
/** `hb_status` / `receive_status` 展示文案(只读)。 */
redPacketStatusText?: string
}
type ForwardedMessageItem = {
messageType: number
@@ -51,6 +87,7 @@ type RedPacketContent = {
title: string
description?: string
url?: string
pay?: RedPacketPaymentInfo
}
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
type ImageContent = {
@@ -522,12 +559,25 @@ function parseShareMessage(content: string): ParsedContent {
}
}
if (appMsgType === '2001') {
if (appMsgType === '2001' || /mmpayhb|receivehongbao|wxpay:\/\/c2cbizmessagehandler\/hongbao/i.test(content)) {
const pay = parseWcpayInfo(content)
const url = decodeXmlUrl(extractXmlValue(content, 'url')) || undefined
return {
type: 'redPacket',
title: extractXmlValue(content, 'title') || '微信红包',
description: extractXmlValue(content, 'des') || '恭喜发财,大吉大利',
url: decodeXmlUrl(extractXmlValue(content, 'url')) || undefined
title:
pay.sendTitle ||
pay.receiveTitle ||
extractXmlValue(content, 'title') ||
'微信红包',
description:
extractXmlValue(content, 'des') ||
pay.sceneText ||
'恭喜发财,大吉大利',
url,
pay: {
...pay,
sendId: pay.sendId || extractSendIdFromUrl(url)
}
}
}
@@ -557,7 +607,55 @@ function parseShareMessage(content: string): ParsedContent {
url,
appname,
typeVal,
articles: articles.length > 1 ? articles : undefined
articles: articles.length > 1 ? articles : undefined,
transfer: typeVal === '2000' ? parseWcpayInfo(content) : undefined
}
}
/** 只读解析 `<wcpayinfo>`(转账 / 红包展示字段;不做支付)。 */
function parseWcpayInfo(content: string): TransferPaymentInfo & RedPacketPaymentInfo {
const block = /<wcpayinfo>([\s\S]*?)<\/wcpayinfo>/i.exec(content)?.[1] || content
const val = (tag: string): string | undefined => {
const raw = extractXmlValue(block, tag)
return raw ? decodeXmlEntities(raw) || undefined : undefined
}
const transferStatus = val('transfer_status')
const hbStatus = val('hb_status')
const receiveStatus = val('receive_status')
return {
paySubtype: val('paysubtype'),
amountText: val('feedesc'),
transcationId: val('transcationid'),
transferId: val('transferid'),
invalidTime: val('invalidtime'),
beginTransferTime: val('begintransfertime'),
effectiveDate: val('effectivedate'),
payMemo: val('pay_memo') || undefined,
receiverUsername: val('receiver_username'),
payerUsername: val('payer_username'),
transferStatus,
transferStatusText: describeTransferStatus(transferStatus),
templateId: val('templateid'),
receiveTitle: val('receivertitle'),
sendTitle: val('sendertitle'),
sceneText: val('scenetext'),
senderDes: val('senderdes'),
receiverDes: val('receiverdes'),
iconUrl: decodeXmlUrl(val('iconurl') || '') || undefined,
nativeUrl: val('nativeurl'),
hbType: val('hb_type'),
hbStatus,
receiveStatus,
redPacketStatusText: describeRedPacketStatus(hbStatus, receiveStatus)
}
}
function extractSendIdFromUrl(url?: string): string | undefined {
if (!url) return undefined
try {
return new URL(url).searchParams.get('sendid') || undefined
} catch {
return /sendid=(\d+)/i.exec(url)?.[1]
}
}
+39 -1
View File
@@ -155,6 +155,37 @@ import type {
TextToSpeechSettingsResult
} from '../shared/text-to-speech'
export type TransferPaymentInfo = {
paySubtype?: string
amountText?: string
transcationId?: string
transferId?: string
invalidTime?: string
beginTransferTime?: string
effectiveDate?: string
payMemo?: string
receiverUsername?: string
payerUsername?: string
transferStatus?: string
transferStatusText?: string
}
export type RedPacketPaymentInfo = {
templateId?: string
receiveTitle?: string
sendTitle?: string
sceneText?: string
senderDes?: string
receiverDes?: string
iconUrl?: string
nativeUrl?: string
sendId?: string
hbType?: string
hbStatus?: string
receiveStatus?: string
redPacketStatusText?: string
}
export type ParsedContent =
| { type: 'text'; content: string }
| { type: 'voice'; duration?: number }
@@ -167,6 +198,7 @@ export type ParsedContent =
url: string
appname?: string
typeVal?: string
transfer?: TransferPaymentInfo
}
| {
type: 'miniProgram'
@@ -178,7 +210,13 @@ export type ParsedContent =
thumbDatName?: string
thumbDataUrl?: string
}
| { type: 'redPacket'; title: string; description?: string; url?: string }
| {
type: 'redPacket'
title: string
description?: string
url?: string
pay?: RedPacketPaymentInfo
}
| { type: 'voip'; duration?: number; status: string; roomType?: number }
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
| {
@@ -158,7 +158,22 @@ function CardBubble({ data }: { data: Extract<ParsedContent, { type: 'card' }> }
}
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
const { title, des, url, appname, articles } = data
const { title, des, url, appname, articles, transfer, typeVal } = data
if (String(typeVal) === '2000' || transfer) {
const pay = transfer || {}
const meta = [pay.amountText, pay.payMemo ? `备注:${pay.payMemo}` : '', pay.transferStatusText]
.filter(Boolean)
.join(' · ')
return (
<div className="transfer-message">
<div className="transfer-kicker">微信转账</div>
<div className="transfer-title">{title || '微信转账'}</div>
<div className="transfer-meta">{meta || des || '转账消息'}</div>
{pay.transferId ? <div className="transfer-footer">单号 {pay.transferId}</div> : null}
</div>
)
}
if (articles?.length) {
return (
@@ -254,6 +269,8 @@ function RedPacketBubble({
const handleClick = (): void => {
if (data.url) window.open(data.url, '_blank')
}
const statusText = data.pay?.redPacketStatusText
const blurb = data.description || data.pay?.sceneText || '恭喜发财,大吉大利'
return (
<div
@@ -266,7 +283,7 @@ function RedPacketBubble({
</span>
<span className="red-packet-copy">
<strong>{data.title}</strong>
<small>{data.description || '恭喜发财,大吉大利'}</small>
<small>{statusText ? `${blurb} · ${statusText}` : blurb}</small>
</span>
</div>
<div className="red-packet-footer">微信红包</div>
+37
View File
@@ -364,6 +364,43 @@
line-height: 16px;
}
.transfer-message {
width: min(280px, 48vw);
overflow: hidden;
border: 1px solid var(--wxex-border);
border-radius: 6px;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
padding: 12px 14px;
}
.transfer-kicker {
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 18px;
}
.transfer-title {
margin-top: 2px;
font-size: 15px;
font-weight: 600;
line-height: 22px;
}
.transfer-meta {
margin-top: 6px;
color: var(--wxex-text-primary);
font-size: 13px;
line-height: 20px;
}
.transfer-footer {
margin-top: 8px;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 16px;
}
.red-packet-message {
width: min(280px, 48vw);
overflow: hidden;
+107
View File
@@ -0,0 +1,107 @@
/**
* 转账 / 红包状态文案(只读展示)。
* 文案对齐微信 UI 簇(待收款/已收款/…、待领取/已领取/…)。
* 数字枚举为推断对齐;未知值原样回退。
*/
const TRANSFER_STATUS_TEXT: Record<string, string> = {
'0': '待收款',
'1': '已收款,待入账',
'2': '已收款',
'3': '已退还',
'4': '过期未收款,已退还',
'5': '你已退还',
'6': '已过期',
'7': '此笔转账已被撤回',
paid: '已收款',
received: '已收款',
pending: '待收款',
unreceived: '待收款',
refunded: '已退还',
expired: '过期未收款,已退还',
withdrawn: '此笔转账已被撤回',
revoked: '此笔转账已被撤回',
confirmed: '朋友已确认收款',
accepted: '已被接收',
accepting: '已被接收'
}
const RED_PACKET_STATUS_TEXT: Record<string, string> = {
'0': '待领取',
'1': '已领取',
'2': '已被领完',
'3': '已过期',
'4': '已被领取',
received: '已领取',
claimed: '已领取',
expired: '已过期',
exhausted: '已被领完',
pending: '待领取',
unclaimed: '待领取'
}
const TRANSFER_STATUS_KEYWORDS: Array<[RegExp, string]> = [
[/撤回|withdraw|revoke/i, '此笔转账已被撤回'],
[/过期未收款|expired/i, '过期未收款,已退还'],
[/已退还|退还|refund/i, '已退还'],
[/待入账|pending.?credit/i, '已收款,待入账'],
[/已收款|已收|paid|received/i, '已收款'],
[/已被接收|accepted|accepting/i, '已被接收'],
[/确认收款|confirmed/i, '朋友已确认收款'],
[/待收款|请收款|pending|unreceived/i, '待收款'],
[/已过期|expired/i, '已过期']
]
const RED_PACKET_STATUS_KEYWORDS: Array<[RegExp, string]> = [
[/领完|exhausted/i, '已被领完'],
[/已被领取|已领取|received|claimed/i, '已领取'],
[/过期|expired/i, '已过期'],
[/待领取|未领取|pending|unclaimed/i, '待领取']
]
function describeByMap(
raw: string | number | undefined,
table: Record<string, string>,
keywords: Array<[RegExp, string]>
): string | undefined {
if (raw === undefined || raw === null || raw === '') return undefined
const text = String(raw).trim()
if (!text) return undefined
const direct = table[text.toLowerCase()] || table[text]
if (direct) return direct
for (const [re, label] of keywords) {
if (re.test(text)) return label
}
return text
}
/** `transfer_status` → 展示文案(未知原样返回)。 */
export function describeTransferStatus(raw?: string | number): string | undefined {
return describeByMap(raw, TRANSFER_STATUS_TEXT, TRANSFER_STATUS_KEYWORDS)
}
/** 红包 `hb_status` / `receive_status` → 展示文案。 */
export function describeRedPacketStatus(
hbStatus?: string | number,
receiveStatus?: string | number
): string | undefined {
return (
describeByMap(hbStatus, RED_PACKET_STATUS_TEXT, RED_PACKET_STATUS_KEYWORDS) ||
describeByMap(receiveStatus, RED_PACKET_STATUS_TEXT, RED_PACKET_STATUS_KEYWORDS)
)
}
/** `paysubtype` 仅作辅助标签(真机见过 1/3/4;语义未在 dylib 钉死)。 */
export function describePaySubtype(raw?: string | number): string | undefined {
if (raw === undefined || raw === null || raw === '') return undefined
const text = String(raw).trim()
const table: Record<string, string> = {
'1': '转账',
'3': '收款',
'4': '转账'
}
return table[text] || text
}
export const TRANSFER_STATUS_LABELS = TRANSFER_STATUS_TEXT
export const RED_PACKET_STATUS_LABELS = RED_PACKET_STATUS_TEXT
+39
View File
@@ -97,6 +97,43 @@ type ShareContent = {
appname?: string
typeVal?: string
articles?: ShareArticle[]
/** `wcpayinfo` when typeVal === '2000' (微信转账). Display/export only. */
transfer?: TransferPaymentInfo
}
/** 转账 `wcpayinfo` 字段(只读展示;不含支付操作)。 */
export type TransferPaymentInfo = {
paySubtype?: string
amountText?: string
transcationId?: string
transferId?: string
invalidTime?: string
beginTransferTime?: string
effectiveDate?: string
payMemo?: string
receiverUsername?: string
payerUsername?: string
transferStatus?: string
/** `transfer_status` 展示文案(只读)。 */
transferStatusText?: string
}
/** 红包 `wcpayinfo` 字段(只读展示)。 */
export type RedPacketPaymentInfo = {
templateId?: string
receiveTitle?: string
sendTitle?: string
sceneText?: string
senderDes?: string
receiverDes?: string
iconUrl?: string
nativeUrl?: string
sendId?: string
hbType?: string
hbStatus?: string
receiveStatus?: string
/** 展示文案(只读)。 */
redPacketStatusText?: string
}
export type ForwardedMessageItem = {
messageType: number
@@ -126,6 +163,8 @@ type RedPacketContent = {
title: string
description?: string
url?: string
/** `wcpayinfo` 展示字段(只读)。 */
pay?: RedPacketPaymentInfo
}
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
type ImageContent = {
+68
View File
@@ -457,6 +457,74 @@ describe('export media', () => {
expect(html).toContain("if (event.key === 'Escape')")
expect(html).toContain('closeLightbox()')
})
it('always surfaces transfer and red-packet amount, memo and status wording', () => {
const html = renderExportPage('支付可读导出')
const dom = new JSDOM(html, { runScripts: 'outside-only' })
const messages: Message[] = [
{
...messageForArchive('transfer-paid', 'fixture', '支付可读导出', '收到转账2900.00元。', 1),
type: '转账',
contentData: {
type: 'share',
typeVal: '2000',
title: '微信转账',
des: '收到转账2900.00元。',
url: '',
transfer: {
amountText: '¥2900.00',
payMemo: '房租',
transferStatus: '2',
transferStatusText: '已收款',
transferId: '1000050001202609130232821248942',
paySubtype: '3'
}
}
},
{
...messageForArchive('hb-claimed', 'fixture', '支付可读导出', '我给你发了一个红包,赶紧去拆!', 2),
type: '微信红包',
contentData: {
type: 'redPacket',
title: '中秋快乐',
description: '我给你发了一个红包,赶紧去拆!',
pay: {
sendTitle: '中秋快乐',
sceneText: '微信红包',
hbStatus: '1',
redPacketStatusText: '已领取',
sendId: '1000039801202609247176036834007',
hbType: '1'
}
}
}
]
Object.assign(dom.window, {
__WECHAT_EXPORT__: {
version: 1,
sourceId: 'fixture',
name: '支付可读导出',
exportedAt: '2026-09-24T12:00:00.000Z',
messages
}
})
dom.window.eval(inlineScriptOf(html))
const cards = [...dom.window.document.querySelectorAll('.payment-content')]
expect(cards).toHaveLength(2)
const transfer = cards.find((node) => node.classList.contains('transfer'))!
const redPacket = cards.find((node) => node.classList.contains('red-packet'))!
// 即使消息带 des,金额/备注/状态也不能被盖住。
expect(transfer.textContent).toContain('¥2900.00')
expect(transfer.textContent).toContain('备注:房租')
expect(transfer.textContent).toContain('已收款')
expect(transfer.textContent).toContain('收到转账2900.00元。')
expect(transfer.textContent).toContain('单号 1000050001202609130232821248942')
expect(redPacket.textContent).toContain('已领取')
expect(redPacket.textContent).toContain('我给你发了一个红包,赶紧去拆!')
dom.window.close()
})
})
function messageForArchive(
+84
View File
@@ -206,4 +206,88 @@ describe('message parser', () => {
messageType: 999
})
})
it('parses transfer wcpayinfo fields (type 2000)', () => {
// 2026-09 真机转账采样字段名(含微信原文 transcationid 拼写)
const xml = [
'<msg><appmsg appid="" sdkver="">',
'<title><![CDATA[微信转账]]></title>',
'<des><![CDATA[收到转账2900.00元。]]></des>',
'<type>2000</type>',
'<url><![CDATA[https://support.weixin.qq.com/upgrade]]></url>',
'<wcpayinfo>',
'<paysubtype>3</paysubtype>',
'<feedesc><![CDATA[¥2900.00]]></feedesc>',
'<transcationid><![CDATA[53010003370089202609134087894875]]></transcationid>',
'<transferid><![CDATA[1000050001202609130232821248942]]></transferid>',
'<invalidtime><![CDATA[1789348128]]></invalidtime>',
'<begintransfertime><![CDATA[1789261728]]></begintransfertime>',
'<effectivedate><![CDATA[1]]></effectivedate>',
'<pay_memo><![CDATA[房租]]></pay_memo>',
'<receiver_username><![CDATA[wxid_receiver]]></receiver_username>',
'<payer_username><![CDATA[]]></payer_username>',
'<transfer_status>2</transfer_status>',
'</wcpayinfo></appmsg></msg>'
].join('')
const parsed = parseMessageContent(xml, 49)
expect(parsed).toMatchObject({
type: 'share',
typeVal: '2000',
title: '微信转账',
transfer: {
paySubtype: '3',
amountText: '¥2900.00',
transcationId: '53010003370089202609134087894875',
transferId: '1000050001202609130232821248942',
invalidTime: '1789348128',
beginTransferTime: '1789261728',
effectiveDate: '1',
payMemo: '房租',
receiverUsername: 'wxid_receiver',
transferStatus: '2',
transferStatusText: '已收款'
}
})
})
it('parses red packet wcpayinfo fields (type 2001 / mmpayhb)', () => {
const xml = [
'<msg><appmsg appid="" sdkver="">',
'<title><![CDATA[中秋快乐]]></title>',
'<des><![CDATA[我给你发了一个红包,赶紧去拆!]]></des>',
'<type>2001</type>',
'<wcpayinfo>',
'<templateid><![CDATA[7a2a165d31da7fce6dd77e05c300028a]]></templateid>',
'<url><![CDATA[https://wxapp.tenpay.com/mmpayhb/wxhb_personalreceive?msgtype=1&sendid=1000039801202609247176036834007&sign=abc]]></url>',
'<iconurl><![CDATA[https://wx.gtimg.com/hongbao/1800/hb.png]]></iconurl>',
'<receivertitle><![CDATA[恭喜发财,大吉大利]]></receivertitle>',
'<sendertitle><![CDATA[中秋快乐,望重置]]></sendertitle>',
'<scenetext><![CDATA[微信红包]]></scenetext>',
'<senderdes><![CDATA[查看红包]]></senderdes>',
'<receiverdes><![CDATA[领取红包]]></receiverdes>',
'<nativeurl><![CDATA[wxpay://c2cbizmessagehandler/hongbao/receivehongbao?sendid=1]]></nativeurl>',
'<hb_type>1</hb_type>',
'<hb_status>1</hb_status>',
'<receive_status>2</receive_status>',
'</wcpayinfo></appmsg></msg>'
].join('')
const parsed = parseMessageContent(xml, 49)
expect(parsed).toMatchObject({
type: 'redPacket',
title: '中秋快乐,望重置',
pay: {
templateId: '7a2a165d31da7fce6dd77e05c300028a',
receiveTitle: '恭喜发财,大吉大利',
sendTitle: '中秋快乐,望重置',
sceneText: '微信红包',
senderDes: '查看红包',
receiverDes: '领取红包',
sendId: '1000039801202609247176036834007',
hbType: '1',
hbStatus: '1',
receiveStatus: '2',
redPacketStatusText: '已领取'
}
})
})
})
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import {
describePaySubtype,
describeRedPacketStatus,
describeTransferStatus
} from '../../src/shared/payment-status'
describe('payment status labels', () => {
it('maps transfer_status numbers and keywords to UI wording', () => {
expect(describeTransferStatus(2)).toBe('已收款')
expect(describeTransferStatus('3')).toBe('已退还')
expect(describeTransferStatus('paid')).toBe('已收款')
expect(describeTransferStatus('收款方过期未收款,已退还')).toBe('过期未收款,已退还')
expect(describeTransferStatus(undefined)).toBeUndefined()
})
it('maps red packet hb_status / receive_status', () => {
expect(describeRedPacketStatus('0', undefined)).toBe('待领取')
expect(describeRedPacketStatus('1', undefined)).toBe('已领取')
expect(describeRedPacketStatus(undefined, '2')).toBe('已被领完')
expect(describeRedPacketStatus('已被领取', undefined)).toBe('已领取')
expect(describeRedPacketStatus(undefined, undefined)).toBeUndefined()
})
it('passes through unknown status values', () => {
expect(describeTransferStatus('99-custom')).toBe('99-custom')
expect(describeRedPacketStatus('99', '98')).toBe('99')
expect(describePaySubtype('3')).toBe('收款')
expect(describePaySubtype('9')).toBe('9')
})
})