feat: 飞书单点登录和通知功能

This commit is contained in:
2026-02-03 11:38:16 +08:00
parent c657fbe01a
commit 78ebc67e2a
18 changed files with 2136 additions and 105 deletions

266
src/services/lark.js Normal file
View File

@@ -0,0 +1,266 @@
/**
* 飞书消息服务
* 支持应用机器人(私聊/群聊)和自定义机器人(群聊 webhook
*/
const crypto = require('crypto');
const config = require('../config/env');
const logger = require('../utils/logger');
let accessToken = null;
let tokenExpireAt = 0;
/**
* 获取应用机器人的 access_token用于私聊
*/
async function getAccessToken() {
const { appId, appSecret } = config.lark;
if (!appId || !appSecret) {
throw new Error('飞书应用未配置:请设置 LARK_APP_ID 和 LARK_APP_SECRET');
}
//如果 token 还有效,直接返回
if (accessToken && Date.now() < tokenExpireAt - 60000) {
return accessToken;
}
const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app_id: appId, app_secret: appSecret })
});
const data = await res.json();
if (data.code !== 0) {
throw new Error(`获取飞书 access_token 失败: ${data.msg}`);
}
accessToken = data.tenant_access_token;
tokenExpireAt = Date.now() + data.expire * 1000;
logger.info('[Lark] Access token refreshed');
return accessToken;
}
/**
* 生成签名(用于自定义机器人 webhook
*/
function genSign(timestamp, secret) {
const stringToSign = `${timestamp}\n${secret}`;
const hmac = crypto.createHmac('sha256', stringToSign);
return hmac.digest('base64');
}
/**
* 通过自定义机器人 webhook 发送消息(群聊)
*/
async function sendWebhook(webhookUrl, message, secret = null) {
const body = { ...message };
if (secret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
body.timestamp = timestamp;
body.sign = genSign(timestamp, secret);
}
const res = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.code !== 0) {
throw new Error(`飞书 webhook 发送失败: ${data.msg || JSON.stringify(data)}`);
}
return data;
}
/**
* 通过应用机器人 API 发送消息(私聊/群聊)
* @param {string} receiveId - 接收者 IDopen_id/user_id/chat_id
* @param {string} receiveIdType - ID 类型open_id | user_id | union_id | email | chat_id
* @param {string} msgType - 消息类型text | post | interactive
* @param {object} content - 消息内容
*/
async function sendMessage(receiveId, receiveIdType, msgType, content) {
const token = await getAccessToken();
const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
receive_id: receiveId,
msg_type: msgType,
content: typeof content === 'string' ? content : JSON.stringify(content)
})
});
const data = await res.json();
if (data.code !== 0) {
throw new Error(`飞书消息发送失败: ${data.msg || JSON.stringify(data)}`);
}
return data;
}
/**
* 发送文本消息(便捷方法)
*/
async function sendText(receiveId, receiveIdType, text, atUsers = []) {
let finalText = text;
//添加 @ 用户
if (atUsers && atUsers.length > 0) {
const atTags = atUsers.map(uid =>
uid === 'all' ? '<at user_id="all">所有人</at>' : `<at user_id="${uid}"></at>`
).join(' ');
finalText = `${atTags} ${text}`;
}
return sendMessage(receiveId, receiveIdType, 'text', { text: finalText });
}
/**
* 发送富文本消息
*/
async function sendPost(receiveId, receiveIdType, title, content, atUsers = []) {
const postContent = {
zh_cn: {
title,
content: [[]]
}
};
//添加 @ 用户
if (atUsers && atUsers.length > 0) {
atUsers.forEach(uid => {
postContent.zh_cn.content[0].push({
tag: 'at',
user_id: uid
});
});
postContent.zh_cn.content[0].push({ tag: 'text', text: ' ' });
}
//添加正文
postContent.zh_cn.content[0].push({ tag: 'text', text: content });
return sendMessage(receiveId, receiveIdType, 'post', { post: postContent });
}
/**
* 发送 webhook 文本消息(便捷方法)
*/
async function sendWebhookText(webhookUrl, text, secret = null, atUsers = []) {
let finalText = text;
if (atUsers && atUsers.length > 0) {
const atTags = atUsers.map(uid =>
uid === 'all' ? '<at user_id="all">所有人</at>' : `<at user_id="${uid}"></at>`
).join(' ');
finalText = `${atTags} ${text}`;
}
return sendWebhook(webhookUrl, {
msg_type: 'text',
content: { text: finalText }
}, secret);
}
/**
* 发送 webhook 富文本消息
*/
async function sendWebhookPost(webhookUrl, title, content, secret = null, atUsers = []) {
const postContent = [[{ tag: 'text', text: content }]];
if (atUsers && atUsers.length > 0) {
const atElements = atUsers.map(uid => ({
tag: 'at',
user_id: uid
}));
postContent[0] = [...atElements, { tag: 'text', text: ' ' }, ...postContent[0]];
}
return sendWebhook(webhookUrl, {
msg_type: 'post',
content: {
post: {
zh_cn: { title, content: postContent }
}
}
}, secret);
}
/**
* 通过手机号或邮箱获取用户 open_id
* @param {string} identifier - 手机号或邮箱
* @returns {string} open_id
*/
async function getOpenIdByContact(identifier) {
const token = await getAccessToken();
const isEmail = identifier.includes('@');
const payload = isEmail
? { emails: [identifier] }
: { mobiles: [identifier] };
const res = await fetch('https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id?user_id_type=open_id', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.code !== 0) {
throw new Error(`获取用户 open_id 失败: ${data.msg}`);
}
const userList = data.data?.user_list || [];
if (userList.length === 0 || !userList[0].user_id) {
throw new Error(`未找到用户: ${identifier}`);
}
return userList[0].user_id;
}
/**
* 获取配置的默认 webhook URL
*/
function getDefaultWebhookUrl() {
return config.lark.webhookUrl;
}
/**
* 获取配置的默认 webhook secret
*/
function getDefaultWebhookSecret() {
return config.lark.webhookSecret || null;
}
module.exports = {
getAccessToken,
genSign,
sendWebhook,
sendMessage,
sendText,
sendPost,
sendWebhookText,
sendWebhookPost,
getOpenIdByContact,
getDefaultWebhookUrl,
getDefaultWebhookSecret
};