场景教程:红包与转账消息
红包/转账是“消息 + 业务账本”的组合:IM 只负责把一条可渲染、可触达、可回溯的自定义消息送到对方;金额、余额、领取状态、幂等与对账必须由你的业务服务(账本)负责。本教程给出一套可直接落地的实现,包括消息结构、时序、服务端调用、三端代码、错误处理与限制。
IM 不提供支付能力
本项目没有钱包、余额、支付渠道、红包拆分或资金清算接口;任何金额都不得以 IM 消息内容作为权威账本。
消息可以被撤回(默认 2 分钟窗口)或被审核拦截,因此“消息已送达”≠“钱已到账”;到账状态只能以业务账本为准。
方案:消息只做载体,账本单独存
| 数据 | 存储位置 | 权威性 |
|---|---|---|
| 红包/转账消息 | IM 消息(content.type=custom) | 载体:可展示、可通知、可回溯,不作为金额凭据 |
| 订单/账本 | 业务数据库(订单表、流水表) | 唯一权威:金额、状态、领取人、时间 |
| 消息与订单关联 | 自定义内容里的 orderId + messageId | 双向可查:消息点击 → 订单;订单 → 原消息 |
| 实时状态 | message.modify 修改消息内容 + 业务推送 | 展示用;以账本为准 |
消息结构
JSON
{
"type": "custom",
"data": {
"bizType": "redpacket",
"orderId": "rp_20261028_0001",
"amount": 8800,
"currency": "CNY",
"greeting": "恭喜发财,大吉大利",
"status": "created",
"expiresAt": 1790000000000,
"senderName": "张三"
}
}字段全部由业务定义(custom.data 是任意 JSON)。建议固定 bizType 与 orderId,客户端按 bizType 选择渲染器;amount 用最小货币单位整数,避免浮点误差。
时序
- 发送方点击“发红包” → 调业务服务
POST /wallet/redpacket创建订单(业务侧幂等键 = 客户端请求 ID)。 - 业务服务写账本(状态
created),然后以发送者身份调 IMmessage.private.publish发自定义消息,clientMsgId用订单号保证 10 分钟内重试幂等。 - 接收方 IM 收到
MESSAGES事件 → 渲染红包气泡(不展示金额或展示“红包”占位)。 - 接收方点击 → 调业务服务领取接口(业务侧以
orderId + userId做幂等),账本完成扣减/入账。 - 业务服务用
message.modify把消息状态改为opened(或expired),并通过业务通道通知双方刷新。
服务端:发消息 + 改状态(Node SDK)
JavaScript
import { AppClient } from '@ni-paas-im/server-sdk';
const app = new AppClient({
appKey: process.env.IM_APP_KEY,
appSecret: process.env.IM_APP_SECRET,
baseUrl: 'http://127.0.0.1:8080',
});
/** 创建红包订单后调用:把红包消息投递给收款人。 */
export async function sendRedPacket({ fromUserId, toUserId, orderId, amount, greeting }) {
return app.openApi('message.private.publish', {
fromUserId,
toUserId,
clientMsgId: `rp:${orderId}`, // 10 分钟内同 clientMsgId 幂等,重试不会发两条
content: {
type: 'custom',
data: {
bizType: 'redpacket',
orderId,
amount, // 最小货币单位(分)
currency: 'CNY',
greeting,
status: 'created',
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
},
},
// 离线推送文案:接收方离线时由 webhook 链路接收(不含金额)
push: { title: '你收到一个红包', content: '点击查看', forceShowPushContent: true },
});
}
/** 领取成功后调用:把消息状态改为 opened(展示用,不改变账本)。 */
export async function markRedPacketOpened({ messageId, orderId, openedBy }) {
return app.openApi('message.modify', {
messageId,
content: {
type: 'custom',
data: {
bizType: 'redpacket',
orderId,
status: 'opened',
openedBy,
openedAt: Date.now(),
},
},
});
}改状态不要丢掉业务字段
message.modify 是整体替换 content,不是合并。修改时要把 orderId / bizType / 金额等必要字段一并带上,否则客户端渲染会缺字段。
修改成功会触发 message.modified 回调;接收端也可以通过 MESSAGE_EXPANSION_CHANGED(若用扩展)或重新拉历史感知状态。
客户端:发送与接收(Web / Android / Flutter)
import { createClient, ConversationType } from '@ni-paas/im-web';
const client = createClient({ appkey: 'YOUR_APP_KEY', serverUrl: 'http://127.0.0.1:8080' });
await client.connect(TOKEN);
// 业务服务返回 orderId 后再发消息;金额只做展示,账本在服务端
const res = await client.sendMessage(ConversationType.PRIVATE, 'bob', {
type: 'custom',
data: { bizType: 'redpacket', orderId: 'rp_20261028_0001', amount: 8800, currency: 'CNY', status: 'created' },
});
if (res.code !== 0) throw new Error(`${res.code} ${res.msg}`);client.addEventListener(IMClientEvent.MESSAGES) { event ->
event.messages.forEach { message ->
val content = message.content
if (content.type == "custom") {
val bizType = content.data?.optString("bizType")
if (bizType == "redpacket") {
val orderId = content.data?.optString("orderId")
renderRedPacketBubble(message, orderId) // 点击后调业务领取接口
}
}
}
}client.addEventListener('MESSAGES', (event) {
for (final message in event.messages) {
final content = message.content;
if (content.type == 'custom' && content.data?['bizType'] == 'redpacket') {
renderRedPacketBubble(message); // 点击后调业务领取接口
}
}
});接收端渲染约定:默认展示“红包”占位与祝福语,不展示金额;点击后才调业务接口领取,领取结果以业务返回为准。
幂等与对账
| 风险 | 防护 |
|---|---|
| 重试导致重复发消息 | 同一订单使用固定 clientMsgId(如 rp:{orderId});服务端 10 分钟内按 clientMsgId 幂等 |
| 重复领取 | 业务侧以 (orderId, userId) 唯一索引 + 事务扣减;不要依赖 IM 消息状态 |
| 消息改状态失败 | 账本先落库,消息状态可异步补偿(定时重试 message.modify) |
| 消息被撤回/拦截 | 金融消息建议不提供撤回入口;命中审核时以 1010/1035 告知发送方,账本按业务规则回滚或挂起 |
| 对账 | 按 orderId 关联订单表、IM messageId 与消息历史(message.history 审计视图) |
错误处理
| 错误码 | 场景 | 处理建议 |
|---|---|---|
1001 | content 结构不合法 / userId 超长 | 校验 type=custom 且 data 为对象;userId ≤64 字符 |
1004 | 收款人不存在 | 先 user.register 或校验用户;业务侧提示“对方账户不存在” |
1007 | 消息体 >5KB | 自定义内容只放必要字段,其余放业务短链 |
1008 | 发送过于频繁 | 退避重试(retryable=true),不要立即重发 |
1010 | 命中敏感词(祝福语) | 按控制台处置动作:replace 时正文被掩码,block 时返回 1010 |
1011 | 被对方拉黑 | 提示不可发送;金额订单应取消或退回 |
1016 | 发送方/接收方被封禁 | 停止发送;业务侧冻结订单 |
1035 | 发送前同步回调拒绝 | 按回调返回的 reason 提示用户;不要自动重试同一内容 |
限制
| 项 | 值 |
|---|---|
| 消息体 | ≤5KB(序列化后);文本 ≤5,000 字符 |
| 自定义内容 | 任意 JSON,但计入 5KB |
| 幂等窗口 | clientMsgId 10 分钟 |
| 撤回窗口 | 默认 120 秒(可在控制台调整);金融消息建议禁用撤回入口 |
| 消息修改 | 仅发送者或应用侧(operatorId)可改;内容同样 ≤5KB |
| 离线推送 | 接收方无在线会话时走应用 webhook(不含金额等敏感字段更安全) |