优化小程序消息解析逻辑:新增extractMiniProgramInfo函数以从XML中提取小程序关键信息,简化消息渲染流程,提升代码可读性和维护性。同时,移除冗余的JSON解析逻辑,确保直接处理XML字符串。

This commit is contained in:
乘风
2026-01-19 16:16:10 +08:00
parent 54a366836e
commit 1d00f02606
3 changed files with 368 additions and 155 deletions

View File

@@ -0,0 +1,159 @@
# msgType=49 小程序消息真实案例测试
## 测试案例
### 案例 1八达通充值小程序Type 2
#### 原始数据
```
[该消息内容过长已截断]{"contentXml":"<?xml version=\"1.0\"?>\n<msg>\n\t<appmsg appid=\"\" sdkver=\"0\">\n\t\t<title>八达通充值 Octopus Reloading</title>\n\t\t<sourcedisplayname>八达通充值 Octopus Reloading</sourcedisplayname>\n\t\t<weappinfo>\n\t\t\t<type>2</type>\n\t\t\t<weappiconurl><![CDATA[http://wx.qlogo.cn/mmhead/K6CEv0Hv9Dd1oxclTbYft9ddwMMXMWbiaetYd5WXtiaBtRQW7JN8e2nZkKwF8pDXNianpxOYnDE1Fs/96]]></weappiconurl>\n\t\t</weappinfo>\n\t</appmsg>\n</msg>","type":"miniprogram"}
```
#### 关键信息提取
| 字段 | XML 标签 | 提取值 | 说明 |
|------|----------|--------|------|
| 小程序标题 | `<title>` | 八达通充值 Octopus Reloading | 显示在卡片上的标题 |
| 小程序名称 | `<sourcedisplayname>` | 八达通充值 Octopus Reloading | 显示在卡片底部 |
| 小程序类型 | `<weappinfo><type>` | 2 | Type 2 样式(大图展示) |
| 封面图 | `<weappiconurl>` | http://wx.qlogo.cn/mmhead/K6CEv0Hv9Dd1oxclTbYft9ddwMMXMWbiaetYd5WXtiaBtRQW7JN8e2nZkKwF8pDXNianpxOYnDE1Fs/96 | 小程序图标 |
#### 处理流程
1. **前缀处理**
```typescript
// 检测并去掉 [该消息内容过长已截断] 前缀
const truncatedPrefix = "[该消息内容过长已截断]";
if (trimmedContent.startsWith(truncatedPrefix)) {
trimmedContent = trimmedContent.substring(truncatedPrefix.length);
}
```
2. **JSON 解析**
```typescript
const messageData = JSON.parse(trimmedContent);
// messageData = { contentXml: "<?xml version...", type: "miniprogram" }
```
3. **完整解析尝试**
```typescript
try {
// 尝试使用 parseWeappMsgStr 完整解析
const parsedData = parseWeappMsgStr(trimmedContent);
} catch (parseError) {
// 如果失败(例如缺少必要字段),使用 fallback
}
```
4. **Fallback 提取**
```typescript
// 使用正则表达式从 XML 中提取关键信息
const fallbackInfo = extractMiniProgramInfoFromTruncatedXml(messageData.contentXml);
// 提取结果:
// {
// title: "八达通充值 Octopus Reloading",
// appName: "八达通充值 Octopus Reloading",
// miniProgramType: 2,
// previewImage: "http://wx.qlogo.cn/mmhead/..."
// }
```
5. **渲染小程序卡片**
```tsx
// 根据 miniProgramType = 2渲染 Type 2 样式
<div className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`}>
<div className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`}>
<div className={styles.miniProgramAppTop}>八达通充值 Octopus Reloading</div>
<div className={styles.miniProgramTitle}>八达通充值 Octopus Reloading</div>
<div className={styles.miniProgramImageArea}>
<img src="http://wx.qlogo.cn/mmhead/..." alt="小程序图片" />
</div>
<div className={styles.miniProgramContent}>
<div className={styles.miniProgramIdentifier}>小程序</div>
</div>
</div>
</div>
```
## 正则表达式说明
### 1. 提取 title
```javascript
/<title><!\[CDATA\[(.*?)\]\]><\/title>/i // 支持 CDATA 格式
/<title>([^<]+)<\/title>/i // 支持普通格式
```
**示例匹配:**
- `<title>八达通充值 Octopus Reloading</title>` ✅
- `<title><![CDATA[八达通充值]]></title>` ✅
- `<title />` ❌ 过滤空标签
### 2. 提取 sourcedisplayname
```javascript
/<sourcedisplayname><!\[CDATA\[(.*?)\]\]><\/sourcedisplayname>/i
/<sourcedisplayname>([^<]+)<\/sourcedisplayname>/i
```
**示例匹配:**
- `<sourcedisplayname>八达通充值 Octopus Reloading</sourcedisplayname>` ✅
### 3. 提取 weappinfo.type
```javascript
/<weappinfo>[\s\S]*?<type><!\[CDATA\[(.*?)\]\]><\/type>/i
/<weappinfo>[\s\S]*?<type>([^<]+)<\/type>/i
```
**示例匹配:**
- `<weappinfo>...<type>2</type>...</weappinfo>` ✅
- `[\s\S]*?` 匹配任意字符(包括换行)
### 4. 提取 weappiconurl
```javascript
/<weappiconurl><!\[CDATA\[(.*?)\]\]><\/weappiconurl>/i
/<weappiconurl>([^<]+)<\/weappiconurl>/i
```
**示例匹配:**
- `<weappiconurl><![CDATA[http://wx.qlogo.cn/...]]></weappiconurl>` ✅
- 自动清理 CDATA 标记、引号和 `&amp;`
## 调试日志
当消息被正确处理时,控制台会输出:
```
✅ 提取到小程序标题: 八达通充值 Octopus Reloading
✅ 提取到小程序名称 (sourcedisplayname): 八达通充值 Octopus Reloading
✅ 提取到小程序类型: 2
✅ 提取到小程序封面: http://wx.qlogo.cn/mmhead/K6CEv0Hv9Dd1oxclTbYft9ddwMMXMWbiaetYd5WXtiaBtRQW7JN8e2nZkKwF8pDXNianpxOYnDE1Fs/96
✅ XML 信息提取成功: {title: "八达通充值 Octopus Reloading", appName: "八达通充值 Octopus Reloading", miniProgramType: 2, previewImage: "http://..."}
```
## 容错能力
### ✅ 支持的情况
1. **完整 XML** - 使用 `parseWeappMsgStr` 完整解析
2. **残缺 XML** - 使用正则提取关键字段
3. **带前缀** - 自动去除 `[该消息内容过长已截断]`
4. **CDATA 格式** - 支持 `<![CDATA[...]]>` 包裹的内容
5. **空标签** - 自动过滤 `<title />` 等空标签
6. **转义字符** - 自动处理 `&amp;` 等 HTML 实体
### ❌ 不支持的情况
1. **关键字段缺失** - 如果 `title` 和 `sourcedisplayname` 都不存在,返回 null
2. **无效 URL** - 如果 `weappiconurl` 不包含 "http",不提取
3. **非小程序消息** - 如果没有 `<weappinfo>` 标签,可能无法识别类型
## 文件位置
- **组件文件**: `src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx`
- **提取函数**: `extractMiniProgramInfoFromTruncatedXml`(第 11-117 行)
- **调用位置**:
- `contentXml` 检测路径(第 194 行)
- `type === "miniprogram"` 兼容路径(第 346 行)
## 相关文档
- [消息类型配置指南](./消息类型配置指南.md)
- [msgType49拆分-文章vs小程序](./msgType49拆分-文章vs小程序.md)

View File

@@ -115,7 +115,6 @@ export const useMessageTypeParser = (contract: ContractData | weChatGroup) => {
// 1. 如果有明确的 msgType优先查找配置
if (msgType !== undefined && MESSAGE_TYPE_MAP[msgType]) {
const config = MESSAGE_TYPE_MAP[msgType];
console.log(`✅ 使用 msgType=${msgType} (${config.type})`);
return config.nodeFunc(nodeProps);
}

View File

@@ -1,11 +1,144 @@
import React from "react";
import { parseWeappMsgStr } from "@/utils/common";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import styles from "./SmallProgramMessage.module.scss";
const isJsonLike = (value: string) => {
const trimmed = value.trim();
return trimmed.startsWith("{") && trimmed.endsWith("}");
/**
* 从截断的 XML 中提取小程序关键信息
* 重点匹配特征:
* 1. title标签<title>八达通充值 Octopus Reloading</title>
* 2. 封面链接:<![CDATA[http://wx.qlogo.cn/mmhead/...]]>
*/
const extractMiniProgramInfo = (xmlContent: string): {
title?: string;
appName?: string;
miniProgramType?: number;
previewImage?: string;
} | null => {
if (!xmlContent || typeof xmlContent !== "string") {
return null;
}
try {
const result: {
title?: string;
appName?: string;
miniProgramType?: number;
previewImage?: string;
} = {};
// ⭐ 提取 title重点特征匹配支持截断的XML
// 匹配模式:<title>内容</title> 或 <title>内容(截断)
const titleMatch =
// 完整格式:<title>内容</title>
xmlContent.match(/<title>([^<]+)<\/title>/i) ||
// CDATA格式<title><![CDATA[内容]]></title>
xmlContent.match(/<title><!\[CDATA\[([^\]]+)\]\]><\/title>/i) ||
// 截断格式:<title>内容(后面可能没有闭合标签)
xmlContent.match(/<title>([^<\n\r]+?)(?:\s*<|$)/i);
if (titleMatch?.[1]) {
const title = titleMatch[1].trim();
// 过滤空值和无效值
if (title && title !== "/" && title.length > 0) {
result.title = title;
}
}
// 提取 sourcedisplayname小程序显示名称支持截断
const sourcedisplaynameMatch =
xmlContent.match(/<sourcedisplayname>([^<]+)<\/sourcedisplayname>/i) ||
xmlContent.match(/<sourcedisplayname><!\[CDATA\[([^\]]+)\]\]><\/sourcedisplayname>/i) ||
xmlContent.match(/<sourcedisplayname>([^<\n\r]+?)(?:\s*<|$)/i);
if (sourcedisplaynameMatch?.[1]) {
const appName = sourcedisplaynameMatch[1].trim();
if (appName && appName !== "/" && appName.length > 0) {
result.appName = appName;
}
}
// 提取 appname备用支持截断
if (!result.appName) {
const appnameMatch =
xmlContent.match(/<appname>([^<]+)<\/appname>/i) ||
xmlContent.match(/<appname><!\[CDATA\[([^\]]+)\]\]><\/appname>/i) ||
xmlContent.match(/<appname>([^<\n\r]+?)(?:\s*<|$)/i);
if (appnameMatch?.[1]) {
const appName = appnameMatch[1].trim();
if (appName && appName !== "/" && appName.length > 0) {
result.appName = appName;
}
}
}
// 提取 weappinfo.type小程序类型1 或 2
const weappinfoTypeMatch =
xmlContent.match(/<weappinfo>[\s\S]*?<type>([^<]+)<\/type>/i) ||
xmlContent.match(/<weappinfo>[\s\S]*?<type><!\[CDATA\[([^\]]+)\]\]><\/type>/i) ||
xmlContent.match(/<type>([^<\n\r]+?)(?:\s*<|$)/i);
if (weappinfoTypeMatch?.[1]) {
const typeNum = parseInt(weappinfoTypeMatch[1].trim());
if (!Number.isNaN(typeNum) && (typeNum === 1 || typeNum === 2)) {
result.miniProgramType = typeNum;
}
}
// ⭐ 提取封面链接重点特征匹配http://wx.qlogo.cn/mmhead/
// 优先匹配完整的 weappiconurl 标签
let previewImageUrl: string | undefined;
const weappiconurlMatch =
xmlContent.match(/<weappiconurl><!\[CDATA\[([^\]]+)\]\]><\/weappiconurl>/i) ||
xmlContent.match(/<weappiconurl>([^<]+)<\/weappiconurl>/i);
if (weappiconurlMatch?.[1]) {
previewImageUrl = weappiconurlMatch[1].trim();
} else {
// ⭐ 如果标签不完整直接搜索包含特征字符的CDATA块
// 匹配模式:<![CDATA[http://wx.qlogo.cn/mmhead/...]]>
const cdataMatch = xmlContent.match(
/<!\[CDATA\[(https?:\/\/wx\.qlogo\.cn\/mmhead\/[^\]]+)\]\]>/i
);
if (cdataMatch?.[1]) {
previewImageUrl = cdataMatch[1];
} else {
// 更宽松的匹配直接搜索包含特征字符的URL
const urlMatch = xmlContent.match(
/(https?:\/\/wx\.qlogo\.cn\/mmhead\/[^\s<"']+)/i
);
if (urlMatch?.[1]) {
previewImageUrl = urlMatch[1];
}
}
}
if (previewImageUrl) {
// 清理URL
let url = previewImageUrl
.replace(/<!\[CDATA\[|\]\]>/g, "")
.replace(/[`"']/g, "")
.replace(/&amp;/g, "&")
.trim();
if (url && url.includes("http")) {
result.previewImage = url;
}
}
// 如果至少提取到了 title 或 appName认为提取成功
if (result.title || result.appName) {
return result;
}
return null;
} catch (error) {
console.warn("从 XML 提取信息失败:", error);
return null;
}
};
interface SmallProgramMessageProps {
@@ -14,12 +147,15 @@ interface SmallProgramMessageProps {
contract: ContractData | weChatGroup;
}
/**
* 小程序消息渲染组件
* 处理格式:[该消息内容过长已截断]<?xml version="1.0"?>...
*/
const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
content,
msg,
contract,
}) => {
// 统一的错误消息渲染函数
const renderErrorMessage = (fallbackText: string) => (
<div className={styles.messageText}>{fallbackText}</div>
);
@@ -29,162 +165,81 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
}
try {
// 去掉 [该消息内容过长已截断] 前缀
// 去掉 [该消息内容过长已截断] 前缀
let trimmedContent = content.trim();
const truncatedPrefix = "[该消息内容过长已截断]";
if (trimmedContent.startsWith(truncatedPrefix)) {
trimmedContent = trimmedContent.substring(truncatedPrefix.length);
trimmedContent = trimmedContent.substring(truncatedPrefix.length).trim();
}
const isJsonContent = isJsonLike(trimmedContent);
const messageData = isJsonContent ? JSON.parse(trimmedContent) : null;
if (messageData && typeof messageData === "object") {
// ⭐ 检测小程序消息(通过 contentXml 字段)
if (messageData.contentXml && typeof messageData.contentXml === "string") {
try {
const parsedData = parseWeappMsgStr(trimmedContent);
if (parsedData.appmsg) {
const { appmsg } = parsedData;
const title = appmsg.title || "小程序消息";
const appName =
appmsg.sourcedisplayname || appmsg.appname || "小程序";
const miniProgramType =
appmsg.weappinfo && appmsg.weappinfo.type
? parseInt(appmsg.weappinfo.type)
: 1;
if (miniProgramType === 2) {
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`}
>
<div
className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`}
>
<div className={styles.miniProgramAppTop}>{appName}</div>
<div className={styles.miniProgramTitle}>{title}</div>
<div className={styles.miniProgramImageArea}>
<img
src={parsedData.previewImage}
alt="小程序图片"
className={styles.miniProgramImage}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
</div>
<div className={styles.miniProgramContent}>
<div className={styles.miniProgramIdentifier}></div>
</div>
</div>
</div>
);
}
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
>
<div className={styles.miniProgramCard}>
<img
src={parsedData.previewImage}
alt="小程序缩略图"
className={styles.miniProgramThumb}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
<div className={styles.miniProgramInfo}>
<div className={styles.miniProgramTitle}>{title}</div>
</div>
</div>
<div className={styles.miniProgramApp}>{appName}</div>
</div>
);
}
} catch (parseError) {
console.error("小程序消息解析失败 (contentXml):", parseError);
}
}
// ⭐ 兼容旧格式type === "miniprogram"
if (messageData.type === "miniprogram") {
try {
const parsedData = parseWeappMsgStr(trimmedContent);
if (parsedData.appmsg) {
const { appmsg } = parsedData;
const title = appmsg.title || "小程序消息";
const appName =
appmsg.sourcedisplayname || appmsg.appname || "小程序";
const miniProgramType =
appmsg.weappinfo && appmsg.weappinfo.type
? parseInt(appmsg.weappinfo.type)
: 1;
if (miniProgramType === 2) {
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`}
>
<div
className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`}
>
<div className={styles.miniProgramAppTop}>{appName}</div>
<div className={styles.miniProgramTitle}>{title}</div>
<div className={styles.miniProgramImageArea}>
<img
src={parsedData.previewImage}
alt="小程序图片"
className={styles.miniProgramImage}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
</div>
<div className={styles.miniProgramContent}>
<div className={styles.miniProgramIdentifier}></div>
</div>
</div>
</div>
);
}
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
>
<div className={styles.miniProgramCard}>
<img
src={parsedData.previewImage}
alt="小程序缩略图"
className={styles.miniProgramThumb}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
<div className={styles.miniProgramInfo}>
<div className={styles.miniProgramTitle}>{title}</div>
</div>
</div>
<div className={styles.miniProgramApp}>{appName}</div>
</div>
);
}
} catch (parseError) {
console.error("parseWeappMsgStr解析失败:", parseError);
return renderErrorMessage("[小程序消息 - 解析失败]");
}
}
// trimmedContent 直接就是 XML 字符串,不需要 JSON 解析
// 从 XML 中提取信息
const info = extractMiniProgramInfo(trimmedContent);
if (!info) {
return renderErrorMessage("[小程序消息 - 信息提取失败]");
}
return renderErrorMessage("[小程序消息]");
const title = info.title || "小程序消息";
const appName = info.appName || "小程序";
const miniProgramType = info.miniProgramType || 1;
const previewImage = info.previewImage || "";
// 根据类型渲染不同的 UI
if (miniProgramType === 2) {
// 类型 2垂直图片布局
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`}
>
<div
className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`}
>
<div className={styles.miniProgramAppTop}>{appName}</div>
{previewImage && (
<div className={styles.miniProgramImageArea}>
<img
src={previewImage}
alt="小程序图片"
className={styles.miniProgramImage}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
</div>
)}
<div className={styles.miniProgramContent}>
<div className={styles.miniProgramIdentifier}></div>
</div>
</div>
</div>
);
}
// 类型 1默认横向布局
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
>
<div className={styles.miniProgramCard}>
{previewImage && (
<img
src={previewImage}
alt="小程序缩略图"
className={styles.miniProgramThumb}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
)}
<div className={styles.miniProgramInfo}>
<div className={styles.miniProgramTitle}>{title}</div>
</div>
</div>
<div className={styles.miniProgramApp}>{appName}</div>
</div>
);
} catch (e) {
console.warn("小程序消息解析失败:", e);
return renderErrorMessage("[小程序消息 - 解析失败]");