场景教程:红包与转账消息

红包/转账是“消息 + 业务账本”的组合: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 用最小货币单位整数,避免浮点误差。

时序​

  1. 发送方点击“发红包” → 调业务服务 POST /wallet/redpacket 创建订单(业务侧幂等键 = 客户端请求 ID)。
  2. 业务服务写账本(状态 created),然后以发送者身份调 IM message.private.publish 发自定义消息,clientMsgId 用订单号保证 10 分钟内重试幂等。
  3. 接收方 IM 收到 MESSAGES 事件 → 渲染红包气泡(不展示金额或展示“红包”占位)。
  4. 接收方点击 → 调业务服务领取接口(业务侧以 orderId + userId 做幂等),账本完成扣减/入账。
  5. 业务服务用 message.modify 把消息状态改为 opened(或 expired),并通过业务通道通知双方刷新。
发送方 App 点“发红包” 业务服务 / 账本 建单 → 调 IM 发消息 领取幂等 / 对账 IM 服务 投递消息 接收方 App 领取 → 刷新

服务端:发消息 + 改状态(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 审计视图)

错误处理​

错误码场景处理建议
1001content 结构不合法 / 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(不含金额等敏感字段更安全)

相关页面​