likes
comments
collection
share

NestJS 实战海外社媒项目: 平台消息收发

作者站长头像
站长
· 阅读数 61

前言

在前端大环境不好的情况下,多掌握一种后端技能,可以更好地补全我们在业务深度上的短板。

前置准备

这里先简单梳理下各社媒平台相关的消息 API 及相关使用流程。

社媒平台消息 API 地址
LINELINE API
FacebookFacebook API
InstagramInstagram API
WhatsAppWhatsApp API

在花了一些时间来研究后,我们可以看出这几个平台都可以直接创建账号及开发者应用,然后提供了一定免费额度的消息来测试。

此外,发消息的方式是类似的,基本都是通过ACCESS_TOKEN来调用 RESTful API,消息类型上,大致都有文本、图片、视频、模板等类型消息。这些初步的分析有助于后续的数据库及 API 设计

💡注意点: 各社媒平台在账号及权限开通上有不少要注意的地方,后续如果有机会再详细整理一下。

数据库设计及实现

定义数据模型

既然我们要存消息,那么肯定涉及到消息实体,然后还要有一个用户实体,用于记录用户,这两个关键实体的基本定义如下:

  • 用户信息(UserInfo) : 包括用户ID、社媒平台标识、社媒平台用户ID、用户名、头像等。
  • 消息记录(Message) : 记录每条消息的发送和接收,包括消息ID、用户ID、消息内容、消息类型、时间、是否为用户发送等。

实现数据库

数据表设计

在这一节,我们将具体介绍每个实体的表设计,包括字段、数据类型及索引等。例如:

  • 用户信息表(UserInfo)

    • id: String (主键)
    • platform: String (平台标识)
    • platformUserId: String (平台用户ID)
    • userName: String (用户名)
    • avatarUrl: String (用户头像)
    • ...
  • 消息记录表(Message)

    • id: String (主键)
    • userId: String (用户ID)
    • type: String (消息类型)
    • content: String (消息内容)
    • isFromUser: Boolean (是否为用户发送)
    • createTime: Date (创建时间)
    • ...

上面包含了 UserInfo 和 Message 表的必要字段,不过由于各社媒平台的实现差异,还有不少字段不便直接落到字段中,比如语言、性别、年龄等,这时可以额外增加一个 extraInfo:JSON 字段来存放这些信息。

实体代码

在编写实体代码之前,涉及的一些公共枚举可以先定义出来,分别是平台标识和消息类型:

NestJS 实战海外社媒项目: 平台消息收发

新建 user-infomessage 两个模块,UserInfo 和 Message 实体定义分别如下,在 xxx.module.ts 中进行配置之后,运行项目即可在数据库中生成对应的表,两张表的关系是一对多关系,即一个用户对应多条消息,此时可以编写基本的 CRUD 方法进行测试。

NestJS 实战海外社媒项目: 平台消息收发

NestJS 实战海外社媒项目: 平台消息收发

API 设计及实现

在这一节,我们将设计并实现几个关键的 API 接口,包括查询用户、查询消息、发送消息、接收 Webhook 消息等。

设计 RESTful API

几个关键的 API 接口的基本定义如下:

  • 查询用户:GET /user-info,获取所有用户信息。
  • 查询消息:GET /message/user/{userId},按用户ID检索历史消息。
  • 发送消息:POST /message/send/{platform},根据平台标识,发送各社媒平台消息。
  • 接收消息:POST /webhook/{platform},配置社媒平台的 Webhook 接口,实时接收社媒平台的消息。

实现 API

查询用户、查询消息

这两个接口的实现相对简单,不涉及具体的业务操作,代码实现如下:

/user-info

// src/user-info/user-info.controller.ts
@Controller('user-info')
export class UserInfoController {
  constructor(private readonly userInfoService: UserInfoService) {}

  @Get()
  findAll() {
    return this.userInfoService.findAll();
  }
}

// src/user-info/user-info.service.ts
@Injectable()
export class UserInfoService {
  constructor(
    @InjectRepository(UserInfo)
    private messageRepository: Repository<UserInfo>,
  ) {}
  
  findAll(): Promise<UserInfo[]> {
    return this.messageRepository.find();
  }
}

/message/user/{userId}

// src/message/message.controller
@Controller('message')
export class MessageController {
  constructor(private readonly messageService: MessageService) {}
  
  @Get('user/:userId')
  findByUserId(@Param('userId') userId: string) {
    return this.messageService.findByUserId(userId);
  }
}
// src/user-info/user-info.service.ts
@Injectable()
export class MessageService {
  constructor(
    @InjectRepository(Message)
    private messageRepository: Repository<Message>,
  ) {}

  findByUserId(userId: string): Promise<Message[]> {
    return this.messageRepository
      .createQueryBuilder('message')
      .where('message.userId = :userId', { userId })
      .getMany();
  }
}

发送消息

/message/send 接口涉及主要的业务流程处理,实现上会稍复杂些,这里会一步步来讲解。

社媒配置

在社媒后台的配置上,LINE 和 Meta 系APP(Facebook、Instagram、WhatsApp)是类似的,开通了应用之后,可以去生成 ACCESS_TOKEN,然后拿 ACCESS_TOKEN 及一些必要配置去调消息 API,通过一些现有的消息 SDK 可以让我们更轻松地发送消息。

下面是社媒后台的截图,区别于 LINE, WhatsApp、Facebook、Instagram 可以在一个 Meta 平台上操作,图二中还包含了官方提供的 WhatsApp 消息发送示例。 NestJS 实战海外社媒项目: 平台消息收发

NestJS 实战海外社媒项目: 平台消息收发

SDK 接入

在 SDK 接入前,先在 .env 中定义一些必要的配置项,方便后续部署时动态配置。

这里面 LINE_SECRET 是可以直接从 LINE 应用中获取到。WHATSAPP_PHONE_NUMBER_ID 是发送 WhatsApp 消息时,WhatsApp 要求配置一个面向用户的手机号,需要经过一些的配置来拿到 ID。

Instagram 不需要单独的配置,因为 Instagram 后台账号需要跟 Facebook 账号绑定在一起,然后通过同一个配置即可发送消息。

# LINE配置
LINE_SECRET=
LINE_ACCESS_TOKEN=

# WhatsApp配置
WHATSAPP_ACCESS_TOKEN=
WHATSAPP_API_VERSION=v18.0
WHATSAPP_PHONE_NUMBER_ID=

# Facebook配置
FACEBOOK_ACCESS_TOKEN=
FACEBOOK_API_VERSION=v18.0

安装以下的 SDK,这些 SDK 都是官方提供的,主要用于收发消息、操作账号等。

pnpm install @line/bot-sdk
pnpm install fb
pnpm install whatsapp

创建 utils/facebook、utils/line、utils/whatsapp 相关目录及文件,这里使用 provider 的方式配置 SDK,用 service 做一层业务封装,最后用 module 导出去。

这样主要的好处是用 Nest 提供依赖注入的方式来管理 SDK,在性能和维护上更有帮助;其次抽出业务逻辑和配置层,使代码职责更清晰,调用方只需要关注自身的业务就好。

NestJS 实战海外社媒项目: 平台消息收发

NestJS 实战海外社媒项目: 平台消息收发

其他模块需要调用时,在 module 文件中导入 SDK,然后在 constructor 中注入依赖并调用 service 方法即可,其他 SDK 的实现大同小异,这里就不一一说明,相关代码已上传到 github 仓库。

@Injectable()
export class MessageService {
  constructor(
    private readonly facebookService: FacebookService,
  ) {}

  async sendMessage() {
    this.facebookService.sendTextMessage(userId, content)
  }
}

业务实现

在上面的步骤中,我们已经完成了 UserInfo 和 Message 实体,以及一些 CRUD 方法和 SDK 的实现,发送消息业务会把这些代码进一步串起来。

定义一个创建消息的 DTO 及 controller 方法。

// src/message/dto/create-message.dto.ts
export class CreateMessageDto {
  @ApiProperty({
    description: '用户ID',
    type: String,
  })
  userId: string;

  @ApiProperty({
    description: '消息类型',
    type: String,
    enum: Object.values(MessageType),
  })
  type: MessageType;

  @ApiProperty({
    description: '消息内容',
    type: String,
  })
  content: string;

  @ApiProperty({
    description: '是否是用户发送的消息',
    type: Boolean,
  })
  isFromUser?: boolean;
}

// src/message/message.controller.ts
@Controller('message')
export class MessageController {
  constructor(private readonly messageService: MessageService) {}

  @Post('send:platform')
  @ApiOperation({ summary: '发送不同平台的消息' })
  create(
    @Param('platform') platform: Platform,
    @Body() createMessageDto: CreateMessageDto,
  ) {
    return this.messageService.sendMessage(platform, createMessageDto);
  }
 }

然后在 service 中实现主要的业务流程。可以从代码中看出,这个接口先用 userId 查出用户对应的平台 userId,然后会去调用不同平台的 SDK 来发送消息,如果消息发送成功,返回结果会是 true,否则将会被异常过滤器拦截而返回 null。

// src/message/message.service.ts
@Injectable()
export class MessageService {
  constructor(
    @InjectRepository(Message)
    private messageRepository: Repository<Message>,
    private readonly userInfoService: UserInfoService,
    private readonly facebookService: FacebookService,
    private readonly lineService: LineService,
    private readonly whatsappService: WhatsAppService,
  ) {}

  /**
   * 发送不同平台的消息
   * @param platform - 消息平台
   * @param createMessageDto - 消息参数
   * @returns 处理结果,是否成功
   */
  async sendMessage(
    platform: Platform,
    createMessageDto: CreateMessageDto,
  ): Promise<boolean> {
    const { userId, type, content } = createMessageDto;
    const { platformUserId } = await this.userInfoService.findOne(userId);

    switch (platform) {
      case Platform.FACEBOOK:
      case Platform.INSTAGRAM:
        await this.sendFacebookMessage(platformUserId, type, content);
        break;

      case Platform.LINE:
        await this.sendLineMessage(platformUserId, type, content);

        break;
      case Platform.WHATSAPP:
        await this.sendWhatsAppMessage(platformUserId, type, content);

        break;
      default:
        throw new Error(`不支持的平台: ${platform}`);
    }

    createMessageDto.isFromUser = false;
    await this.saveMessage(createMessageDto);

    return true;
  }

  async saveMessage(createMessageDto: CreateMessageDto) {
    const message = this.messageRepository.create(createMessageDto);
    await this.messageRepository.save(message);
  }

  private async sendFacebookMessage(
    userId: string,
    type: MessageType,
    content: string,
  ) {
    if (type === MessageType.TEXT) {
      return this.facebookService.sendTextMessage(userId, content);
    } else if (type === MessageType.IMAGE || type === MessageType.VIDEO) {
      return this.facebookService.sendMediaMessage(userId, type, content);
    }
  }

  private async sendLineMessage(
    userId: string,
    type: MessageType,
    content: string,
  ) {
    if (type === MessageType.TEXT) {
      return this.lineService.sendTextMessage(userId, content);
    } else if (type === MessageType.IMAGE || type === MessageType.VIDEO) {
      return this.lineService.sendMediaMessage(userId, type, content);
    }
  }

  private async sendWhatsAppMessage(
    userId: string,
    type: MessageType,
    content: string,
  ) {
    if (type === MessageType.TEXT) {
      return this.whatsappService.sendTextMessage(Number(userId), content);
    } else if (type === MessageType.IMAGE || type === MessageType.VIDEO) {
      return this.whatsappService.sendMediaMessage(
        Number(userId),
        type,
        content,
      );
    }
  }
}

整个发送消息的业务看起来很简单,但是为了抽象和适配各个平台的差异点,还有很多边界、细节需要处理,为了简化实现,这里暂时只支持文本、图片、视频消息,后续的 WebHook 接收消息也是如此。

接收消息

在完成了发送消息这个主要流程后,/webhook/{platform} 接口就比较简单些了,接收消息主要涉及以下的业务处理:

  1. 验证 WebHook:部分平台在设置 WebHook 回调之前,需要先通过 GET 接口验证有效性。
  2. 创建用户:因为社媒平台的隐私限制,用户数据一般是随着消息推送过来的,所有需要在这个节点上创建用户;
  3. 保存消息:解析不同平台的数据,并且按照系统的定义把数据保存下来。

LINE 配置 WebHook 示例: NestJS 实战海外社媒项目: 平台消息收发

创建 webhook 模块以及 controller 方法。 NestJS 实战海外社媒项目: 平台消息收发

service 中实现验证 WebHook 和接收消息方法,主要代码如下:

// src/webhook/webhook.service.ts
@Injectable()
export class WebhookService {
  constructor(
    private readonly configService: ConfigService,
    private readonly messageService: MessageService,
    private readonly userInfoService: UserInfoService,
  ) {}

  /**
   * 处理不同平台的Webhook消息
   * @param platform - 消息来源平台
   * @param body - 消息体
   * @returns 处理结果,是否成功
   */
  async handleMessage(
    platform: Platform,
    body: Record<string, any>,
  ): Promise<boolean> {
    console.log(`${platform} webhook receive:`, JSON.stringify(body));

    let result;

    // 根据平台解析消息
    switch (platform) {
      case Platform.FACEBOOK:
      case Platform.INSTAGRAM:
        result = this.parseFacebookMessage(body);
        break;
      case Platform.LINE:
        result = this.parseLineMessage(body);
        break;
      case Platform.WHATSAPP:
        result = this.parseWhatsAppMessage(body);
        break;
      default:
        throw new Error(`不支持的平台: ${platform}`);
    }

    if (!result || result.messages.length === 0) {
      return false;
    }

    // 创建一个 Map 来存储 platformUserId 和对应的 userId
    const userIdMap = new Map<string, string>();

    // 先保存或更新联系人信息
    for (const contact of result.contacts) {
      const existingUser = await this.userInfoService.findOneByPlatformUserId(
        contact.platformUserId,
      );

      let userId: string;
      if (existingUser) {
        userId = existingUser.id;
      } else {
        // 如果用户不存在,创建新用户
        const newUser = await this.userInfoService.create({
          ...contact,
          platform,
        });
        userId = newUser.id;
      }

      userIdMap.set(contact.platformUserId, userId);
    }

    // 然后保存消息
    for (const message of result.messages) {
      const userId = userIdMap.get(message.platformUserId);
      if (userId) {
        await this.messageService.saveMessage({
          ...message,
          userId,
        });
      } else {
        console.error(`无法找到对应的用户ID: ${message.platformUserId}`);
      }
    }

    return true;
  }

  /**
   * 解析Facebook 或 Instagram消息
   * @param body - 消息体
   * @returns 解析结果,包含消息和联系人信息
   */
  parseFacebookMessage(body: Record<string, any>) {
    const entry = body.entry?.[0];
    const messaging = entry?.messaging;

    if (!body.object || !messaging) {
      return { messages: [], contacts: [] };
    }

    const messages = [];
    const contacts = [];

    for (const msg of messaging) {
      const senderId = msg.sender?.id;
      const timestamp = msg.timestamp;
      const message = msg.message;
      const text = message?.text;
      const attachment = message?.attachments?.[0];
      const type = attachment?.type || (text ? MessageType.TEXT : null);

      if (!message || message.is_echo || !type) {
        continue;
      }

      messages.push({
        type,
        createTime: new Date(timestamp),
        isFromUser: true,
        content: text || attachment.payload.url,
        platformUserId: senderId,
      });

      contacts.push({
        platformUserId: senderId,
        userName: 'unknown',
      });
    }

    return { messages, contacts };
  }

  /**
   * 处理Line平台的Webhook消息
   * @param body - 消息体
   * @returns 解析结果,包含消息和联系人信息
   */
  parseLineMessage(body: Record<string, any>) {
    // 篇幅有限,省略代码
  }

  /**
   * 处理WhatsApp平台的Webhook消息
   * @param body - 消息体
   * @returns 解析结果,包含消息和联系人信息
   */
  parseWhatsAppMessage(body: Record<string, any>) {
   // 篇幅有限,省略代码
  }

  /**
   * 验证Webhook (WhatsApp、Facebook需要)
   * @param platform - 平台名称
   * @param query - 查询参数
   * @returns 验证结果,返回challenge
   */
  async verifyWebhook(platform: string, query: any): Promise<string> {
    const mode = query['hub.mode'];
    const token = query['hub.verify_token'];
    const challenge = query['hub.challenge'];
    const webhookToken = this.configService.get('WEBHOOK_TOKEN');

    if (mode && token && mode === 'subscribe' && token === webhookToken) {
      return challenge;
    } else {
      throw new BadRequestException('无效的数据');
    }
  }
}

处理 WebHook 消息的主要麻烦点在于,社媒平台返回的数据结构较复杂,并且会夹带着各种类型的消息、各种类型的事件,比如用户已读、用户关注、用户资料更新等推送。

还有拿到的用户数据、素材数据有一定限制,比如只返回用户 ID,昵称、头像等需要额外调接口查询;素材只返回 ID 或者素材 URL 是临时的,需要二次查询和转存,上面的代码中暂不处理这些细节问题。

下面贴一些 WebHook 推送的消息,可以对照理解一下。

# LINE文本消息
{
    "destination": "U4bd2ba8d422eb700d3140e8877610663",
    "events": [
        {
            "type": "message",
            "message": {
                "type": "text",
                "id": "17915033746832",
                "text": "我是消息内容~"
            },
            "webhookEventId": "01GX31GPQSECK5QP1K24PQ6PHB",
            "deliveryContext": {
                "isRedelivery": true
            },
            "timestamp": 1681109999779,
            "source": {
                "type": "user",
                "userId": "Uaf347dda21bad6b912f7371a85f1cd9a"
            },
            "replyToken": "b9b0227d2e104933a7cfc304a030dbdc",
            "mode": "active"
        }
    ]
}

# Facebook文本消息
{
    "object": "page",
    "entry": [
        {
            "id": "113993058289897",
            "time": 1681892865347,
            "messaging": [
                {
                    "sender": {
                        "id": "6147401975307762"
                    },
                    "recipient": {
                        "id": "113993058289897"
                    },
                    "timestamp": 1681892853468,
                    "message": {
                        "mid": "m_b8_YapsezWrPRGPlQum-zoEpHAJaAP9jluvrfyMcuZ138DOF8VbJVaMB8ELl3k7UgguAK8AgelmFSzmopl6N1w",
                        "text": "我还是消息内容~"
                    }
                }
            ]
        }
    ]
}

总结

在日常的前端开发中,大部分情况下我们都是直接上手写页面,调接口,在代码的整体设计和实现上不需要太多的思考。

但在后端这种重业务型的开发中,良好的设计是整个系统的基石,“牵一发则动全身”,比如表结构设计不合理的话,那么相关上下游业务处理、前端都有变动的风险,所以在写后端项目时,预先考虑设计、写技术方案都是一些较稳妥的办法,还可以拉上其他开发小伙伴一起评估。

本文的 Nest 代码主要涉及了用户管理及消息收发功能,通过定义 UserInfo 和 Message 实体及基本的 CRUD 方法、社媒 SDK 和 WebHook 接入,串在一起就基本形成了一个简单的客服系统。整体功能实现上还不太完备,比如上面提到的各种消息类型的处理、用户信息及媒体资源的补全、高并发优化等,不过“千里之行始于足下”,先实现一个 MVP 也是一个可行的方式。

如有问题,欢迎指正,该系列文章代码在:github.com/weijhfly/ne…

转载自:https://juejin.cn/post/7391324758859284520
评论
请登录