1. 引言
在现代企业应用开发中,数据库系统与即时通讯工具的集成变得越来越重要。SQL Server作为一款功能强大的关系型数据库管理系统,可以通过存储过程实现各种自动化任务,包括发送通知消息。本文将详细介绍如何在SQL Server中创建存储过程,通过HTTP请求与飞书开放平台API交互,实现发送富文本卡片消息的功能。
1.1 应用场景
SQL Server发送飞书卡片消息的应用场景非常广泛,包括但不限于:
- 数据库监控告警:当数据库出现性能问题、空间不足或关键作业失败时自动通知DBA团队
- 业务流程通知:在订单处理、库存变更等业务操作完成后通知相关人员
- 定时报表推送:定期将关键业务数据以卡片形式发送给管理层
- 审批流程触发:当数据库中的审批状态变更时通知审批人
- 系统集成:作为企业应用集成的一部分,连接数据库系统与协作平台
1.2 技术概览
实现这一功能主要涉及以下技术组件:
- SQL Server CLR集成:允许在SQL Server中执行.NET代码
- HTTP客户端:用于向飞书API发送请求
- JSON处理:构建和解析飞书API所需的JSON格式消息
- OAuth 2.0认证:获取访问飞书API所需的令牌
- 存储过程封装:提供简洁的数据库接口供其他应用调用
2. 准备工作
2.1 环境要求
在开始实现之前,请确保满足以下环境要求:
- SQL Server 2012或更高版本(支持CLR集成)
- .NET Framework 4.5或更高版本
- 飞书开发者账号及应用权限
- 数据库服务器能够访问互联网(与飞书API通信)
2.2 飞书应用配置
-
创建飞书应用:
- 登录飞书开放平台(https://open.feishu.cn/)
- 进入"开发者后台",点击"创建应用"
- 填写应用名称、描述等基本信息
-
获取凭证信息:
- 应用凭证:App ID和App Secret
- 权限配置:确保已添加"发送消息"权限
- 启用机器人能力
-
获取webhook地址(可选):
- 如果使用webhook方式发送消息,需在机器人配置中获取webhook地址
- 本文主要介绍通过API方式发送
2.3 SQL Server配置
-
启用CLR集成:
sqlsp_configure 'clr enabled', 1 RECONFIGURE
-
设置TRUSTWORTHY ON(仅开发环境建议):
sqlALTER DATABASE YourDatabaseName SET TRUSTWORTHY ON
-
创建非对称密钥和登录(生产环境推荐):
sqlCREATE ASYMMETRIC KEY CLRFeishuKey FROM EXECUTABLE FILE = 'C:\path\to\your\assembly.dll' CREATE LOGIN CLRFeishuLogin FROM ASYMMETRIC KEY CLRFeishuKey GRANT EXTERNAL ACCESS ASSEMBLY TO CLRFeishuLogin
3. 实现方案设计
3.1 架构设计
整个解决方案的架构分为以下几个层次:
- 数据库层:存储过程作为入口点,处理业务逻辑
- CLR集成层:.NET程序集处理HTTP通信和JSON序列化
- API通信层:与飞书服务器交互,发送消息
- 安全层:处理认证和授权
3.2 消息流程
- 应用程序或SQL作业调用存储过程
- 存储过程调用CLR函数/方法
- CLR代码构建请求,发送到飞书API
- 飞书API处理请求并返回结果
- 结果返回给调用方
3.3 错误处理设计
完善的错误处理机制应包括:
- API请求失败的重试逻辑
- 详细的错误日志记录
- 返回有意义的错误信息给调用方
- 敏感信息的安全处理
4. CLR程序集实现
4.1 创建C#类库项目
使用Visual Studio创建新的类库项目,命名为"SQLFeishuIntegration"。
4.2 核心代码实现
以下是完整的C#实现代码:
csharp
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Data.SqlTypes;
using Microsoft.SqlServer.Server;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Web.Script.Serialization;
[Serializable]
[DataContract]
public class FeishuAccessTokenResponse
{
[DataMember(Name = "code")]
public int Code { get; set; }
[DataMember(Name = "msg")]
public string Message { get; set; }
[DataMember(Name = "tenant_access_token")]
public string TenantAccessToken { get; set; }
[DataMember(Name = "expire")]
public int Expire { get; set; }
}
[Serializable]
[DataContract]
public class FeishuMessageResponse
{
[DataMember(Name = "code")]
public int Code { get; set; }
[DataMember(Name = "msg")]
public string Message { get; set; }
[DataMember(Name = "data")]
public MessageResponseData Data { get; set; }
}
[Serializable]
[DataContract]
public class MessageResponseData
{
[DataMember(Name = "message_id")]
public string MessageId { get; set; }
}
[Serializable]
[DataContract]
public class CardContent
{
[DataMember(Name = "config")]
public CardConfig Config { get; set; }
[DataMember(Name = "header")]
public CardHeader Header { get; set; }
[DataMember(Name = "elements")]
public List<CardElement> Elements { get; set; }
}
[Serializable]
[DataContract]
public class CardConfig
{
[DataMember(Name = "wide_screen_mode")]
public bool WideScreenMode { get; set; } = true;
[DataMember(Name = "enable_forward")]
public bool EnableForward { get; set; } = true;
}
[Serializable]
[DataContract]
public class CardHeader
{
[DataMember(Name = "title")]
public CardTitle Title { get; set; }
[DataMember(Name = "template")]
public string Template { get; set; }
}
[Serializable]
[DataContract]
public class CardTitle
{
[DataMember(Name = "tag")]
public string Tag { get; set; } = "plain_text";
[DataMember(Name = "content")]
public string Content { get; set; }
}
[Serializable]
[DataContract]
public class CardElement
{
[DataMember(Name = "tag")]
public string Tag { get; set; }
[DataMember(Name = "text")]
public CardText Text { get; set; }
[DataMember(Name = "fields")]
public List<CardField> Fields { get; set; }
[DataMember(Name = "actions")]
public List<CardAction> Actions { get; set; }
}
[Serializable]
[DataContract]
public class CardText
{
[DataMember(Name = "tag")]
public string Tag { get; set; } = "lark_md";
[DataMember(Name = "content")]
public string Content { get; set; }
}
[Serializable]
[DataContract]
public class CardField
{
[DataMember(Name = "is_short")]
public bool IsShort { get; set; }
[DataMember(Name = "text")]
public CardText Text { get; set; }
}
[Serializable]
[DataContract]
public class CardAction
{
[DataMember(Name = "tag")]
public string Tag { get; set; } = "button";
[DataMember(Name = "text")]
public CardText Text { get; set; }
[DataMember(Name = "type")]
public string Type { get; set; }
[DataMember(Name = "url")]
public string Url { get; set; }
}
public class FeishuMessageSender
{
private const string TokenUrl = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal";
private const string MessageUrl = "https://open.feishu.cn/open-apis/im/v1/messages";
private static Dictionary<string, Tuple<string, DateTime>> _tokenCache = new Dictionary<string, Tuple<string, DateTime>>();
private static readonly object _lock = new object();
[SqlProcedure]
public static void SendFeishuCardMessage(
SqlString appId,
SqlString appSecret,
SqlString receiveId,
SqlString receiveIdType,
SqlString title,
SqlString content,
SqlString buttonText,
SqlString buttonUrl,
out SqlString resultMessage,
out SqlInt32 resultCode)
{
resultMessage = "";
resultCode = -1;
try
{
// 获取访问令牌
string accessToken = GetAccessToken(appId.Value, appSecret.Value);
if (string.IsNullOrEmpty(accessToken))
{
resultMessage = "Failed to get access token";
return;
}
// 构建卡片消息
var cardContent = new CardContent
{
Config = new CardConfig(),
Header = new CardHeader
{
Title = new CardTitle { Content = title.Value },
Template = "blue"
},
Elements = new List<CardElement>
{
new CardElement
{
Tag = "div",
Text = new CardText { Content = content.Value }
}
}
};
if (!string.IsNullOrEmpty(buttonText.Value) && !string.IsNullOrEmpty(buttonUrl.Value))
{
cardContent.Elements.Add(new CardElement
{
Tag = "action",
Actions = new List<CardAction>
{
new CardAction
{
Text = new CardText { Content = buttonText.Value },
Type = "default",
Url = buttonUrl.Value
}
}
});
}
var messageData = new
{
receive_id = receiveId.Value,
content = new JavaScriptSerializer().Serialize(cardContent),
msg_type = "interactive"
};
string jsonData = new JavaScriptSerializer().Serialize(messageData);
// 发送消息
var response = SendHttpRequest(
$"{MessageUrl}?receive_id_type={receiveIdType.Value}",
"POST",
jsonData,
new Dictionary<string, string>
{
{ "Authorization", $"Bearer {accessToken}" },
{ "Content-Type", "application/json" }
});
var serializer = new DataContractJsonSerializer(typeof(FeishuMessageResponse));
using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(response)))
{
var messageResponse = (FeishuMessageResponse)serializer.ReadObject(ms);
if (messageResponse.Code == 0)
{
resultCode = 0;
resultMessage = $"Message sent successfully. Message ID: {messageResponse.Data.MessageId}";
}
else
{
resultMessage = $"Failed to send message: {messageResponse.Message}";
}
}
}
catch (Exception ex)
{
resultMessage = $"Error: {ex.Message}";
}
}
private static string GetAccessToken(string appId, string appSecret)
{
string cacheKey = $"{appId}_{appSecret}";
lock (_lock)
{
if (_tokenCache.ContainsKey(cacheKey))
{
var cachedToken = _tokenCache[cacheKey];
if (DateTime.Now < cachedToken.Item2.AddMinutes(-5)) // 提前5分钟过期
{
return cachedToken.Item1;
}
_tokenCache.Remove(cacheKey);
}
var requestData = new
{
app_id = appId,
app_secret = appSecret
};
string jsonData = new JavaScriptSerializer().Serialize(requestData);
string response = SendHttpRequest(TokenUrl, "POST", jsonData, null);
var serializer = new DataContractJsonSerializer(typeof(FeishuAccessTokenResponse));
using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(response)))
{
var tokenResponse = (FeishuAccessTokenResponse)serializer.ReadObject(ms);
if (tokenResponse.Code == 0)
{
_tokenCache[cacheKey] = new Tuple<string, DateTime>(
tokenResponse.TenantAccessToken,
DateTime.Now.AddSeconds(tokenResponse.Expire));
return tokenResponse.TenantAccessToken;
}
else
{
throw new Exception($"Failed to get access token: {tokenResponse.Message}");
}
}
}
}
private static string SendHttpRequest(string url, string method, string data, Dictionary<string, string> headers)
{
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
request.Method = method;
request.Timeout = 30000; // 30秒超时
if (headers != null)
{
foreach (var header in headers)
{
request.Headers.Add(header.Key, header.Value);
}
}
if (method == "POST" || method == "PUT")
{
byte[] dataBytes = Encoding.UTF8.GetBytes(data);
request.ContentType = "application/json";
request.ContentLength = dataBytes.Length;
using (Stream requestStream = request.GetRequestStream())
{
requestStream.Write(dataBytes, 0, dataBytes.Length);
}
}
try
{
using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())
using (Stream responseStream = response.GetResponseStream())
using (StreamReader reader = new StreamReader(responseStream, Encoding.UTF8))
{
return reader.ReadToEnd();
}
}
catch (WebException ex)
{
if (ex.Response != null)
{
using (Stream responseStream = ex.Response.GetResponseStream())
using (StreamReader reader = new StreamReader(responseStream, Encoding.UTF8))
{
string errorResponse = reader.ReadToEnd();
throw new Exception($"HTTP Error: {ex.Status}, Response: {errorResponse}");
}
}
throw;
}
}
}
4.3 代码说明
- 数据模型类:定义了飞书API返回的数据结构
- FeishuMessageSender类:核心功能类,包含发送消息的主要逻辑
- GetAccessToken方法:获取飞书API访问令牌,带缓存机制
- SendHttpRequest方法:通用的HTTP请求发送方法
- SendFeishuCardMessage方法:SQL Server调用的入口点,构建卡片消息并发送
4.4 编译和部署
-
编译项目生成DLL文件
-
在SQL Server中注册程序集:
sqlCREATE ASSEMBLY SQLFeishuIntegration FROM 'C:\path\to\SQLFeishuIntegration.dll' WITH PERMISSION_SET = EXTERNAL_ACCESS;
5. 存储过程实现
5.1 创建存储过程
基于CLR程序集创建存储过程:
sql
CREATE PROCEDURE dbo.sp_SendFeishuCardMessage
@AppId NVARCHAR(100),
@AppSecret NVARCHAR(100),
@ReceiveId NVARCHAR(100),
@ReceiveIdType NVARCHAR(20) = 'open_id', -- 可以是 open_id, user_id, email, chat_id
@Title NVARCHAR(200),
@Content NVARCHAR(MAX),
@ButtonText NVARCHAR(100) = NULL,
@ButtonUrl NVARCHAR(500) = NULL,
@ResultMessage NVARCHAR(MAX) OUTPUT,
@ResultCode INT OUTPUT
AS
EXTERNAL NAME SQLFeishuIntegration.[SQLFeishuIntegration.FeishuMessageSender].SendFeishuCardMessage
GO
5.2 简化版存储过程
为了方便日常使用,可以创建一个简化版的存储过程:
sql
CREATE PROCEDURE dbo.sp_SendSimpleFeishuAlert
@Title NVARCHAR(200),
@Content NVARCHAR(MAX),
@IsSuccess BIT = 1,
@ButtonText NVARCHAR(100) = NULL,
@ButtonUrl NVARCHAR(500) = NULL
AS
BEGIN
DECLARE @AppId NVARCHAR(100) = 'your_app_id';
DECLARE @AppSecret NVARCHAR(100) = 'your_app_secret';
DECLARE @ReceiveId NVARCHAR(100) = 'default_receive_id';
DECLARE @ResultMessage NVARCHAR(MAX);
DECLARE @ResultCode INT;
-- 根据成功/失败设置不同的标题样式
IF @IsSuccess = 1
SET @Title = '✅ ' + @Title;
ELSE
SET @Title = '❌ ' + @Title;
EXEC dbo.sp_SendFeishuCardMessage
@AppId = @AppId,
@AppSecret = @AppSecret,
@ReceiveId = @ReceiveId,
@ReceiveIdType = 'user_id',
@Title = @Title,
@Content = @Content,
@ButtonText = @ButtonText,
@ButtonUrl = @ButtonUrl,
@ResultMessage = @ResultMessage OUTPUT,
@ResultCode = @ResultCode OUTPUT;
IF @ResultCode <> 0
RAISERROR('Failed to send Feishu message: %s', 16, 1, @ResultMessage);
END
GO
5.3 使用示例
sql
-- 示例1:发送简单通知
DECLARE @ResultMsg NVARCHAR(MAX);
DECLARE @ResultCode INT;
EXEC dbo.sp_SendFeishuCardMessage
@AppId = 'your_app_id',
@AppSecret = 'your_app_secret',
@ReceiveId = 'user_id_or_open_id',
@Title = '数据库备份通知',
@Content = '数据库备份已完成,耗时2小时15分钟。备份大小: 45GB',
@ButtonText = '查看详情',
@ButtonUrl = 'https://your-domain.com/backup-reports',
@ResultMessage = @ResultMsg OUTPUT,
@ResultCode = @ResultCode OUTPUT;
SELECT @ResultCode AS ResultCode, @ResultMsg AS ResultMessage;
-- 示例2:使用简化版存储过程
EXEC dbo.sp_SendSimpleFeishuAlert
@Title = '每日销售报表',
@Content = '今日销售额: ¥1,250,000\n订单数: 1,245\n平均客单价: ¥1,004',
@IsSuccess = 1,
@ButtonText = '下载完整报表',
@ButtonUrl = 'https://your-domain.com/reports/daily-sales';
6. 高级功能实现
6.1 支持Markdown格式
飞书卡片消息支持Markdown格式,可以增强消息的可读性:
sql
ALTER PROCEDURE dbo.sp_SendFeishuCardMessageWithMarkdown
@AppId NVARCHAR(100),
@AppSecret NVARCHAR(100),
@ReceiveId NVARCHAR(100),
@ReceiveIdType NVARCHAR(20) = 'open_id',
@Title NVARCHAR(200),
@MarkdownContent NVARCHAR(MAX),
@ButtonText NVARCHAR(100) = NULL,
@ButtonUrl NVARCHAR(500) = NULL,
@ResultMessage NVARCHAR(MAX) OUTPUT,
@ResultCode INT OUTPUT
AS
BEGIN
-- 构建完整的卡片JSON
DECLARE @CardJson NVARCHAR(MAX);
SET @CardJson = N'{
"config": {
"wide_screen_mode": true,
"enable_forward": true
},
"header": {
"title": {
"tag": "plain_text",
"content": "' + REPLACE(@Title, '"', '\"') + '"
},
"template": "blue"
},
"elements": [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": "' + REPLACE(REPLACE(@MarkdownContent, '\', '\\'), '"', '\"') + '"
}
}';
IF @ButtonText IS NOT NULL AND @ButtonUrl IS NOT NULL
BEGIN
SET @CardJson = @CardJson + N',
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {
"tag": "plain_text",
"content": "' + REPLACE(@ButtonText, '"', '\"') + '"
},
"type": "default",
"url": "' + REPLACE(@ButtonUrl, '"', '\"') + '"
}
]
}';
END
SET @CardJson = @CardJson + N']
}';
-- 调用CLR方法发送消息
EXEC dbo.sp_SendFeishuCardMessage
@AppId = @AppId,
@AppSecret = @AppSecret,
@ReceiveId = @ReceiveId,
@ReceiveIdType = @ReceiveIdType,
@Title = @Title,
@Content = @CardJson,
@ButtonText = NULL, -- 已经在JSON中包含
@ButtonUrl = NULL, -- 已经在JSON中包含
@ResultMessage = @ResultMessage OUTPUT,
@ResultCode = @ResultCode OUTPUT;
END
GO
6.2 支持多列布局
飞书卡片支持多列布局,可以更高效地展示信息:
sql
CREATE PROCEDURE dbo.sp_SendFeishuMultiColumnCard
@AppId NVARCHAR(100),
@AppSecret NVARCHAR(100),
@ReceiveId NVARCHAR(100),
@ReceiveIdType NVARCHAR(20) = 'open_id',
@Title NVARCHAR(200),
@Column1Title NVARCHAR(100),
@Column1Content NVARCHAR(MAX),
@Column2Title NVARCHAR(100),
@Column2Content NVARCHAR(MAX),
@ButtonText NVARCHAR(100) = NULL,
@ButtonUrl NVARCHAR(500) = NULL,
@ResultMessage NVARCHAR(MAX) OUTPUT,
@ResultCode INT OUTPUT
AS
BEGIN
DECLARE @CardJson NVARCHAR(MAX);
SET @CardJson = N'{
"config": {
"wide_screen_mode": true,
"enable_forward": true
},
"header": {
"title": {
"tag": "plain_text",
"content": "' + REPLACE(@Title, '"', '\"') + '"
},
"template": "wathet"
},
"elements": [
{
"tag": "div",
"fields": [
{
"is_short": true,
"text": {
"tag": "lark_md",
"content": "**' + REPLACE(@Column1Title, '"', '\"') + '**\n' + REPLACE(REPLACE(@Column1Content, '\', '\\'), '"', '\"') + '"
}
},
{
"is_short": true,
"text": {
"tag": "lark_md",
"content": "**' + REPLACE(@Column2Title, '"', '\"') + '**\n' + REPLACE(REPLACE(@Column2Content, '\', '\\'), '"', '\"') + '"
}
}
]
}';
IF @ButtonText IS NOT NULL AND @ButtonUrl IS NOT NULL
BEGIN
SET @CardJson = @CardJson + N',
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {
"tag": "plain_text",
"content": "' + REPLACE(@ButtonText, '"', '\"') + '"
},
"type": "default",
"url": "' + REPLACE(@ButtonUrl, '"', '\"') + '"
}
]
}';
END
SET @CardJson = @CardJson + N']
}';
EXEC dbo.sp_SendFeishuCardMessage
@AppId = @AppId,
@AppSecret = @AppSecret,
@ReceiveId = @ReceiveId,
@ReceiveIdType = @ReceiveIdType,
@Title = @Title,
@Content = @CardJson,
@ButtonText = NULL,
@ButtonUrl = NULL,
@ResultMessage = @ResultMessage OUTPUT,
@ResultCode = @ResultCode OUTPUT;
END
GO
6.3 定时消息发送
结合SQL Server Agent作业,可以实现定时消息发送:
sql
-- 创建存储过程用于定时发送日报
CREATE PROCEDURE dbo.sp_SendDailyDatabaseReport
AS
BEGIN
DECLARE @ReportDate NVARCHAR(20) = CONVERT(NVARCHAR(10), GETDATE(), 120);
DECLARE @Title NVARCHAR(200) = '数据库日报 - ' + @ReportDate;
DECLARE @Content NVARCHAR(MAX);
DECLARE @ResultMessage NVARCHAR(MAX);
DECLARE @ResultCode INT;
-- 获取数据库状态信息
DECLARE @DbSize NVARCHAR(50);
DECLARE @BackupStatus NVARCHAR(100);
DECLARE @JobStatus NVARCHAR(MAX);
SELECT @DbSize = CONVERT(NVARCHAR(50), SUM(size * 8 / 1024)) + ' MB'
FROM sys.master_files
WHERE database_id = DB_ID();
SELECT @BackupStatus =
CASE WHEN MAX(backup_finish_date) > DATEADD(DAY, -1, GETDATE())
THEN '✅ 最近24小时内有备份'
ELSE '❌ 最近24小时内无备份' END
FROM msdb.dbo.backupset
WHERE database_name = DB_NAME();
-- 获取作业状态
SET @JobStatus = '';
SELECT @JobStatus = @JobStatus +
CASE WHEN run_status = 0 THEN '❌ 失败 - '
WHEN run_status = 1 THEN '✅ 成功 - '
WHEN run_status = 2 THEN '🔄 重试 - '
WHEN run_status = 3 THEN '⏸️ 取消 - '
ELSE '❓ 未知 - ' END +
name + '\n最后运行: ' +
CONVERT(NVARCHAR(20), last_run_outcome_date, 120) + '\n\n'
FROM (
SELECT j.name, h.run_status, MAX(h.run_date) AS last_run_outcome_date
FROM msdb.dbo.sysjobs j
INNER JOIN msdb.dbo.sysjobhistory h ON j.job_id = h.job_id
WHERE h.run_date >= CONVERT(VARCHAR(8), GETDATE(), 112)
GROUP BY j.name, h.run_status
) AS JobStatus;
-- 构建消息内容
SET @Content = '**数据库大小**: ' + @DbSize + '\n' +
'**备份状态**: ' + @BackupStatus + '\n\n' +
'**今日作业执行情况**:\n' + @JobStatus;
-- 发送消息
EXEC dbo.sp_SendSimpleFeishuAlert
@Title = @Title,
@Content = @Content,
@IsSuccess = 1,
@ButtonText = '查看详细报表',
@ButtonUrl = 'https://your-domain.com/db-reports/daily';
END
GO
然后创建SQL Server Agent作业,每天定时执行此存储过程。
7. 安全考虑
7.1 敏感信息保护
-
加密存储凭证:
sql-- 创建数据库主密钥 CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'YourStrongPassword123!'; -- 创建证书 CREATE CERTIFICATE FeishuCert WITH SUBJECT = 'Feishu API Credentials'; -- 创建对称密钥 CREATE SYMMETRIC KEY FeishuSymmetricKey WITH ALGORITHM = AES_256 ENCRYPTION BY CERTIFICATE FeishuCert; -- 加密存储App Secret OPEN SYMMETRIC KEY FeishuSymmetricKey DECRYPTION BY CERTIFICATE FeishuCert; INSERT INTO dbo.FeishuCredentials (AppId, EncryptedAppSecret) VALUES ( 'your_app_id', ENCRYPTBYKEY(KEY_GUID('FeishuSymmetricKey'), 'your_app_secret') ); CLOSE SYMMETRIC KEY FeishuSymmetricKey;
-
安全获取凭证:
sqlCREATE PROCEDURE dbo.sp_GetFeishuCredentials @AppId NVARCHAR(100) OUTPUT, @AppSecret NVARCHAR(100) OUTPUT AS BEGIN OPEN SYMMETRIC KEY FeishuSymmetricKey DECRYPTION BY CERTIFICATE FeishuCert; SELECT @AppId = AppId, @AppSecret = CONVERT(NVARCHAR(100), DECRYPTBYKEY(EncryptedAppSecret)) FROM dbo.FeishuCredentials WHERE IsActive = 1; CLOSE SYMMETRIC KEY FeishuSymmetricKey; END GO
7.2 权限控制
-
最小权限原则:
sql-- 创建专门的角色 CREATE ROLE FeishuMessageSender; -- 只授予必要的执行权限 GRANT EXECUTE ON dbo.sp_SendSimpleFeishuAlert TO FeishuMessageSender; -- 不直接授予基础存储过程的权限 DENY EXECUTE ON dbo.sp_SendFeishuCardMessage TO PUBLIC;
-
审计日志:
sqlCREATE TABLE dbo.FeishuMessageLog ( LogId INT IDENTITY(1,1) PRIMARY KEY, AppId NVARCHAR(100), ReceiveId NVARCHAR(100), ReceiveIdType NVARCHAR(20), Title NVARCHAR(200), Content NVARCHAR(MAX), ButtonText NVARCHAR(100) NULL, ButtonUrl NVARCHAR(500) NULL, ResultCode INT, ResultMessage NVARCHAR(MAX), SentBy NVARCHAR(128) DEFAULT SUSER_SNAME(), SentTime DATETIME DEFAULT GETDATE(), ClientHost NVARCHAR(128) DEFAULT HOST_NAME(), ClientApp NVARCHAR(128) DEFAULT APP_NAME() ); -- 修改存储过程添加日志记录 ALTER PROCEDURE dbo.sp_SendFeishuCardMessage -- 原有参数... AS BEGIN -- 原有逻辑... -- 记录日志 INSERT INTO dbo.FeishuMessageLog ( AppId, ReceiveId, ReceiveIdType, Title, Content, ButtonText, ButtonUrl, ResultCode, ResultMessage ) VALUES ( @AppId, @ReceiveId, @ReceiveIdType, @Title, @Content, @ButtonText, @ButtonUrl, @ResultCode, @ResultMessage ); END GO
8. 性能优化
8.1 令牌缓存优化
在CLR代码中已经实现了内存缓存,还可以添加数据库层面的缓存:
sql
CREATE TABLE dbo.FeishuTokenCache (
AppId NVARCHAR(100) NOT NULL,
AccessToken NVARCHAR(500) NOT NULL,
ExpireTime DATETIME NOT NULL,
LastUpdated DATETIME NOT NULL DEFAULT GETDATE(),
PRIMARY KEY (AppId)
);
-- 修改获取令牌的存储过程
ALTER PROCEDURE dbo.sp_GetFeishuAccessToken
@AppId NVARCHAR(100),
@AppSecret NVARCHAR(100),
@AccessToken NVARCHAR(500) OUTPUT,
@IsNewToken BIT OUTPUT
AS
BEGIN
SET @IsNewToken = 0;
-- 检查缓存中是否有未过期的令牌
SELECT @AccessToken = AccessToken
FROM dbo.FeishuTokenCache
WHERE AppId = @AppId AND ExpireTime > DATEADD(MINUTE, 5, GETDATE());
IF @AccessToken IS NULL
BEGIN
-- 调用CLR方法获取新令牌
DECLARE @ResultMessage NVARCHAR(MAX);
DECLARE @ResultCode INT;
DECLARE @ExpireIn INT;
-- 这里需要扩展CLR方法以返回过期时间
-- 假设有一个新的CLR方法可以返回完整响应
-- 更新缓存
DELETE FROM dbo.FeishuTokenCache WHERE AppId = @AppId;
INSERT INTO dbo.FeishuTokenCache (AppId, AccessToken, ExpireTime)
VALUES (@AppId, @AccessToken, DATEADD(SECOND, @ExpireIn, GETDATE()));
SET @IsNewToken = 1;
END
END
GO
8.2 批量消息发送
对于需要发送多条消息的场景,可以实现批量发送:
sql
CREATE PROCEDURE dbo.sp_SendFeishuCardsBatch
@AppId NVARCHAR(100),
@AppSecret NVARCHAR(100),
@MessageList NVARCHAR(MAX), -- JSON格式的消息列表
@ResultSummary NVARCHAR(MAX) OUTPUT
AS
BEGIN
DECLARE @AccessToken NVARCHAR(500);
DECLARE @IsNewToken BIT;
EXEC dbo.sp_GetFeishuAccessToken @AppId, @AppSecret, @AccessToken OUTPUT, @IsNewToken OUTPUT;
IF @AccessToken IS NULL
BEGIN
SET @ResultSummary = 'Failed to get access token';
RETURN;
END
-- 解析JSON消息列表
-- 这里需要SQL Server 2016+支持JSON功能
DECLARE @Results TABLE (
MessageId INT IDENTITY(1,1),
ReceiveId NVARCHAR(100),
Title NVARCHAR(200),
ResultCode INT,
ResultMessage NVARCHAR(MAX)
);
INSERT INTO @Results (ReceiveId, Title)
SELECT
receive_id, title
FROM OPENJSON(@MessageList)
WITH (
receive_id NVARCHAR(100) '$.receive_id',
receive_id_type NVARCHAR(20) '$.receive_id_type',
title NVARCHAR(200) '$.title',
content NVARCHAR(MAX) '$.content',
button_text NVARCHAR(100) '$.button_text',
button_url NVARCHAR(500) '$.button_url'
);
-- 遍历发送每条消息
DECLARE @CurrentId INT = 1;
DECLARE @MaxId INT = (SELECT MAX(MessageId) FROM @Results);
DECLARE @CurrentReceiveId NVARCHAR(100);
DECLARE @CurrentReceiveIdType NVARCHAR(20);
DECLARE @CurrentTitle NVARCHAR(200);
DECLARE @CurrentContent NVARCHAR(MAX);
DECLARE @CurrentButtonText NVARCHAR(100);
DECLARE @CurrentButtonUrl NVARCHAR(500);
DECLARE @CurrentResultMessage NVARCHAR(MAX);
DECLARE @CurrentResultCode INT;
WHILE @CurrentId <= @MaxId
BEGIN
SELECT
@CurrentReceiveId = r.ReceiveId,
@CurrentTitle = r.Title,
@CurrentReceiveIdType = m.receive_id_type,
@CurrentContent = m.content,
@CurrentButtonText = m.button_text,
@CurrentButtonUrl = m.button_url
FROM @Results r
CROSS APPLY OPENJSON(@MessageList)
WITH (
receive_id NVARCHAR(100) '$.receive_id',
receive_id_type NVARCHAR(20) '$.receive_id_type',
title NVARCHAR(200) '$.title',
content NVARCHAR(MAX) '$.content',
button_text NVARCHAR(100) '$.button_text',
button_url NVARCHAR(500) '$.button_url'
) m
WHERE r.MessageId = @CurrentId AND m.receive_id = r.ReceiveId;
EXEC dbo.sp_SendFeishuCardMessage
@AppId = @AppId,
@AppSecret = @AppSecret,
@ReceiveId = @CurrentReceiveId,
@ReceiveIdType = @CurrentReceiveIdType,
@Title = @CurrentTitle,
@Content = @CurrentContent,
@ButtonText = @CurrentButtonText,
@ButtonUrl = @CurrentButtonUrl,
@ResultMessage = @CurrentResultMessage OUTPUT,
@ResultCode = @CurrentResultCode OUTPUT;
UPDATE @Results
SET
ResultCode = @CurrentResultCode,
ResultMessage = @CurrentResultMessage
WHERE MessageId = @CurrentId;
SET @CurrentId = @CurrentId + 1;
END
-- 生成汇总结果
SELECT @ResultSummary = (
SELECT
Title AS '消息标题',
CASE WHEN ResultCode = 0 THEN '成功' ELSE '失败' END AS '发送状态',
ResultMessage AS '结果详情'
FROM @Results
FOR JSON PATH
);
END
GO
9. 错误处理与监控
9.1 完善的错误处理
sql
ALTER PROCEDURE dbo.sp_SendFeishuCardMessage
-- 原有参数...
AS
BEGIN
BEGIN TRY
BEGIN TRANSACTION;
-- 参数验证
IF @AppId IS NULL OR @AppSecret IS NULL OR @ReceiveId IS NULL OR @Title IS NULL OR @Content IS NULL
BEGIN
RAISERROR('Required parameters are missing', 16, 1);
RETURN;
END
-- 验证receive_id_type
IF @ReceiveIdType NOT IN ('open_id', 'user_id', 'email', 'chat_id')
BEGIN
SET @ReceiveIdType = 'open_id';
END
-- 调用CLR方法
DECLARE @CLRResultMessage NVARCHAR(MAX);
DECLARE @CLRResultCode INT;
EXEC dbo.CLR_SendFeishuCardMessage
@AppId = @AppId,
@AppSecret = @AppSecret,
@ReceiveId = @ReceiveId,
@ReceiveIdType = @ReceiveIdType,
@Title = @Title,
@Content = @Content,
@ButtonText = @ButtonText,
@ButtonUrl = @ButtonUrl,
@ResultMessage = @CLRResultMessage OUTPUT,
@ResultCode = @CLRResultCode OUTPUT;
-- 处理结果
IF @CLRResultCode <> 0
BEGIN
-- 记录详细错误
INSERT INTO dbo.ErrorLog (ProcedureName, ErrorMessage, ErrorDetails)
VALUES ('sp_SendFeishuCardMessage', @CLRResultMessage,
'AppId: ' + ISNULL(@AppId, 'NULL') +
', ReceiveId: ' + ISNULL(@ReceiveId, 'NULL'));
-- 根据错误类型决定是否重试
IF @CLRResultMessage LIKE '%token expired%' OR @CLRResultMessage LIKE '%invalid token%'
BEGIN
-- 令牌过期,清除缓存并重试一次
DELETE FROM dbo.FeishuTokenCache WHERE AppId = @AppId;
EXEC dbo.CLR_SendFeishuCardMessage
@AppId = @AppId,
@AppSecret = @AppSecret,
@ReceiveId = @ReceiveId,
@ReceiveIdType = @ReceiveIdType,
@Title = @Title,
@Content = @Content,
@ButtonText = @ButtonText,
@ButtonUrl = @ButtonUrl,
@ResultMessage = @CLRResultMessage OUTPUT,
@ResultCode = @CLRResultCode OUTPUT;
IF @CLRResultCode <> 0
RAISERROR('Retry failed: %s', 16, 1, @CLRResultMessage);
END
ELSE
BEGIN
RAISERROR('%s', 16, 1, @CLRResultMessage);
END
END
SET @ResultMessage = @CLRResultMessage;
SET @ResultCode = @CLRResultCode;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
SET @ResultMessage = ERROR_MESSAGE();
SET @ResultCode = -1;
-- 记录未处理异常
INSERT INTO dbo.ErrorLog (ProcedureName, ErrorMessage, ErrorDetails)
VALUES ('sp_SendFeishuCardMessage', ERROR_MESSAGE(),
'Line: ' + CAST(ERROR_LINE() AS NVARCHAR(10)) +
', State: ' + CAST(ERROR_STATE() AS NVARCHAR(10)));
END CATCH
END
GO
9.2 监控与报警
创建监控存储过程,检查消息发送失败情况:
sql
CREATE PROCEDURE dbo.sp_MonitorFeishuMessageFailures
@HoursToCheck INT = 24,
@AlertThreshold INT = 5
AS
BEGIN
DECLARE @FailureCount INT;
DECLARE @LastError NVARCHAR(MAX);
SELECT
@FailureCount = COUNT(*),
@LastError = MAX(ResultMessage)
FROM dbo.FeishuMessageLog
WHERE ResultCode <> 0
AND SentTime > DATEADD(HOUR, -@HoursToCheck, GETDATE());
IF @FailureCount >= @AlertThreshold
BEGIN
DECLARE @AlertTitle NVARCHAR(200) = '飞书消息发送失败警报';
DECLARE @AlertContent NVARCHAR(MAX) =
'过去' + CAST(@HoursToCheck AS NVARCHAR) + '小时内共发生' +
CAST(@FailureCount AS NVARCHAR) + '次消息发送失败。\n\n' +
'最后一次错误信息:\n' + @LastError;
-- 发送给管理员,使用不同的接收ID
EXEC dbo.sp_SendSimpleFeishuAlert
@Title = @AlertTitle,
@Content = @AlertContent,
@IsSuccess = 0;
END
END
GO
10. 完整示例与测试
10.1 数据库监控报警示例
sql
-- 监控数据库空间并发送警报
CREATE PROCEDURE dbo.sp_CheckDatabaseSpaceAndAlert
@SpaceThresholdMB INT = 1024 -- 1GB
AS
BEGIN
DECLARE @FreeSpaceMB DECIMAL(10,2);
DECLARE @DbName NVARCHAR(128) = DB_NAME();
DECLARE @Title NVARCHAR(200);
DECLARE @Content NVARCHAR(MAX);
DECLARE @IsCritical BIT = 0;
-- 获取数据库文件空间信息
SELECT @FreeSpaceMB = SUM(CAST(available_bytes AS DECIMAL(10,2)) / 1024 / 1024)
FROM sys.dm_os_volume_stats(DB_ID(), NULL) v
CROSS APPLY sys.database_files f
WHERE f.file_id = 1; -- 主要数据文件
IF @FreeSpaceMB < @SpaceThresholdMB
BEGIN
SET @IsCritical = 1;
SET @Title = '⚠️ 数据库空间不足警报 - ' + @DbName;
SET @Content = '数据库 **' + @DbName + '** 剩余空间仅剩 ' +
CAST(@FreeSpaceMB AS NVARCHAR(20)) + ' MB,低于阈值 ' +
CAST(@SpaceThresholdMB AS NVARCHAR(20)) + ' MB。\n\n' +
'**建议立即处理**,否则可能导致数据库操作失败。';
END
ELSE
BEGIN
SET @Title = '✅ 数据库空间正常 - ' + @DbName;
SET @Content = '数据库 **' + @DbName + '** 剩余空间充足: ' +
CAST(@FreeSpaceMB AS NVARCHAR(20)) + ' MB,' +
'阈值: ' + CAST(@SpaceThresholdMB AS NVARCHAR(20)) + ' MB。';
END
-- 添加详细信息
DECLARE @DetailInfo NVARCHAR(MAX) = '';
SELECT @DetailInfo = @DetailInfo +
'文件: ' + name + '\n' +
'类型: ' + CASE WHEN type = 0 THEN '数据' ELSE '日志' END + '\n' +
'大小: ' + CAST(CAST(size * 8 / 1024 AS DECIMAL(10,2)) AS NVARCHAR(20)) + ' MB\n' +
'使用率: ' + CAST(CAST((size - FILEPROPERTY(name, 'SpaceUsed')) * 8 / 1024 AS DECIMAL(10,2)) AS NVARCHAR(20)) + ' MB 空闲\n\n'
FROM sys.database_files;
SET @Content = @Content + '\n\n**详细文件信息**:\n' + @DetailInfo;
-- 发送警报
EXEC dbo.sp_SendSimpleFeishuAlert
@Title = @Title,
@Content = @Content,
@IsSuccess = CASE WHEN @IsCritical = 1 THEN 0 ELSE 1 END,
@ButtonText = CASE WHEN @IsCritical = 1 THEN '立即处理' ELSE NULL END,
@ButtonUrl = CASE WHEN @IsCritical = 1 THEN 'https://your-domain.com/db-admin?action=cleanup' ELSE NULL END;
END
GO
10.2 业务通知示例
sql
-- 订单处理完成通知
CREATE PROCEDURE dbo.sp_SendOrderProcessedNotification
@OrderId INT,
@CustomerName NVARCHAR(100),
@OrderAmount DECIMAL(18,2),
@ProcessedBy NVARCHAR(100),
@ProcessTime DATETIME
AS
BEGIN
DECLARE @Title NVARCHAR(200) = '订单处理完成通知 #' + CAST(@OrderId AS NVARCHAR(10));
DECLARE @Content NVARCHAR(MAX) =
'**订单编号**: #' + CAST(@OrderId AS NVARCHAR(10)) + '\n' +
'**客户名称**: ' + @CustomerName + '\n' +
'**订单金额**: ¥' + CAST(@OrderAmount AS NVARCHAR(20)) + '\n' +
'**处理人员**: ' + @ProcessedBy + '\n' +
'**处理时间**: ' + CONVERT(NVARCHAR(20), @ProcessTime, 120) + '\n\n' +
'订单已处理完成,请相关人员进行后续跟进。';
EXEC dbo.sp_SendSimpleFeishuAlert
@Title = @Title,
@Content = @Content,
@IsSuccess = 1,
@ButtonText = '查看订单详情',
@ButtonUrl = 'https://your-domain.com/orders/' + CAST(@OrderId AS NVARCHAR(10));
END
GO
10.3 测试脚本
sql
-- 测试简单消息
DECLARE @ResultMsg NVARCHAR(MAX);
DECLARE @ResultCode INT;
EXEC dbo.sp_SendFeishuCardMessage
@AppId = 'your_app_id',
@AppSecret = 'your_app_secret',
@ReceiveId = 'user_id_or_open_id',
@Title = '测试消息',
@Content = '这是一条测试消息,用于验证SQL Server发送飞书卡片的功能。',
@ButtonText = '确认收到',
@ButtonUrl = 'https://your-domain.com/confirm',
@ResultMessage = @ResultMsg OUTPUT,
@ResultCode = @ResultCode OUTPUT;
SELECT @ResultCode AS ResultCode, @ResultMsg AS ResultMessage;
-- 测试Markdown格式消息
DECLARE @MarkdownContent NVARCHAR(MAX) =
'### 数据库性能报告\n' +
'**服务器**: DB-PROD-01\n' +
'**时间**: ' + CONVERT(NVARCHAR(20), GETDATE(), 120) + '\n\n' +
'#### 关键指标\n' +
'- CPU使用率: 78%\n' +
'- 内存压力: 65%\n' +
'- 活动连接数: 142\n\n' +
'```sql\n' +
'SELECT TOP 5 query_text, execution_count\n' +
'FROM sys.query_stats\n' +
'ORDER BY total_worker_time DESC;\n' +
'```';
EXEC dbo.sp_SendFeishuCardMessageWithMarkdown
@AppId = 'your_app_id',
@AppSecret = 'your_app_secret',
@ReceiveId = 'user_id_or_open_id',
@Title = '数据库性能报告',
@MarkdownContent = @MarkdownContent,
@ButtonText = '查看详细报告',
@ButtonUrl = 'https://your-domain.com/db-reports/performance',
@ResultMessage = @ResultMsg OUTPUT,
@ResultCode = @ResultCode OUTPUT;
SELECT @ResultCode AS ResultCode, @ResultMsg AS ResultMessage;
-- 测试多列布局
EXEC dbo.sp_SendFeishuMultiColumnCard
@AppId = 'your_app_id',
@AppSecret = 'your_app_secret',
@ReceiveId = 'user_id_or_open_id',
@Title = '服务器状态概览',
@Column1Title = '数据库服务器',
@Column1Content = '**状态**: 正常运行\n' +
'**CPU**: 45%\n' +
'**内存**: 62%\n' +
'**磁盘**: 78%空闲',
@Column2Title = '应用服务器',
@Column2Content = '**状态**: 警告\n' +
'**CPU**: 85%\n' +
'**内存**: 90%\n' +
'**响应时间**: 2.4s',
@ButtonText = '查看所有服务器',
@ButtonUrl = 'https://your-domain.com/server-monitor',
@ResultMessage = @ResultMsg OUTPUT,
@ResultCode = @ResultCode OUTPUT;
SELECT @ResultCode AS ResultCode, @ResultMsg AS ResultMessage;
11. 总结与最佳实践
11.1 技术总结
本文详细介绍了如何在SQL Server环境中通过存储过程发送飞书卡片消息的完整实现方案,包括:
- CLR集成的基本原理和实现方法
- 飞书开放平台API的认证和消息发送流程
- 多种卡片消息格式的实现(基础、Markdown、多列布局)
- 安全存储敏感信息的策略
- 错误处理和监控机制
- 性能优化和批量处理技术
11.2 最佳实践
-
安全实践:
- 永远不要在代码中硬编码敏感信息
- 使用SQL Server加密功能保护凭证
- 实现最小权限原则
- 记录详细的审计日志
-
性能实践:
- 缓存访问令牌减少API调用
- 考虑批量发送减少网络开销
- 异步处理非关键路径消息
-
维护实践:
- 统一管理消息模板
- 定期检查错误日志
- 监控消息发送成功率
- 文档化接口和使用示例
-
扩展性考虑:
- 设计可插拔的架构,便于支持其他消息平台
- 考虑消息队列处理高并发场景
- 实现模板引擎支持动态内容
11.3 未来扩展方向
- 支持更多飞书卡片功能,如图片、视频等富媒体内容
- 实现消息回调和交互处理
- 开发管理界面用于消息模板管理
- 集成到SQL Server的扩展事件和警报系统
- 支持多租户和更复杂的权限控制
通过本文提供的完整解决方案,企业可以轻松地将SQL Server数据库系统与飞书协作平台集成,实现自动化的监控告警、业务通知和报表推送功能,大大提高工作效率和系统可靠性。