NestJS 实战海外社媒项目: 平台消息收发
前言
在前端大环境不好的情况下,多掌握一种后端技能,可以更好地补全我们在业务深度上的短板。
前置准备
这里先简单梳理下各社媒平台相关的消息 API 及相关使用流程。
社媒平台 | 消息 API 地址 |
---|---|
LINE | LINE API |
Facebook API | |
Instagram API | |
WhatsApp 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
字段来存放这些信息。
实体代码
在编写实体代码之前,涉及的一些公共枚举可以先定义出来,分别是平台标识和消息类型:
新建 user-info
和 message
两个模块,UserInfo 和 Message 实体定义分别如下,在 xxx.module.ts
中进行配置之后,运行项目即可在数据库中生成对应的表,两张表的关系是一对多关系,即一个用户对应多条消息,此时可以编写基本的 CRUD 方法进行测试。
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 消息发送示例。
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,在性能和维护上更有帮助;其次抽出业务逻辑和配置层,使代码职责更清晰,调用方只需要关注自身的业务就好。
其他模块需要调用时,在 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}
接口就比较简单些了,接收消息主要涉及以下的业务处理:
- 验证 WebHook:部分平台在设置 WebHook 回调之前,需要先通过 GET 接口验证有效性。
- 创建用户:因为社媒平台的隐私限制,用户数据一般是随着消息推送过来的,所有需要在这个节点上创建用户;
- 保存消息:解析不同平台的数据,并且按照系统的定义把数据保存下来。
LINE 配置 WebHook 示例:
创建 webhook
模块以及 controller
方法。
在 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