cc FEISHU NOTIFICATION INTEGRATION
本地来源:Knowledge/World/项目/文档/feishu/cc-FEISHU_NOTIFICATION_INTEGRATION.md
飞书业务通知聚合系统 - 完整集成方案
文档版本: v1.0 适用项目: seedvr2.net (Next.js 15 SaaS模板) 创建日期: 2026-01-09 实现方式: 直接集成到 Next.js(无需 Cloudflare Worker)
目录
- 项目现状分析
- 目标功能清单
- 环境变量配置
- 文件结构规划
- 第一阶段:飞书通知服务升级
- 第二阶段:多维表格存档
- 第三阶段:扩展事件类型
- 第四阶段:邮件异常通知
- 第五阶段:网站监控告警
- 第六阶段:API余额监控
- 测试验证清单
- 故障排查指南
1. 项目现状分析
1.1 现有通知系统架构
当前架构:
┌─────────────────────────────────────────────────────┐
│ 支付 Webhook │
│ src/app/api/webhooks/stripe/route.ts │
└────────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ handleWebhookEvent() │
│ src/payment/provider/stripe.ts │
└────────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ sendNotification() │
│ src/notification/notification.ts │
└────────────────────────┬────────────────────────────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ sendMessageToDiscord│ │ sendMessageToFeishu │
│ (Rich Embed) │ │ (纯文本 ❌) │
└─────────────────────┘ └─────────────────────┘
1.2 现有文件清单
| 文件路径 | 行数 | 功能 | 需要修改 |
|---|---|---|---|
src/notification/feishu.ts |
56 | 飞书纯文本通知 | ✅ 需要升级 |
src/notification/discord.ts |
89 | Discord Embed通知 | ❌ 保持不变 |
src/notification/notification.ts |
26 | 统一通知入口 | ✅ 需要升级 |
src/app/api/webhooks/stripe/route.ts |
154 | Stripe Webhook | ❌ 保持不变 |
src/app/api/webhooks/creem/route.ts |
- | Creem Webhook | ❌ 保持不变 |
src/payment/provider/stripe.ts |
2500+ | Stripe支付逻辑 | ⚠️ 可能需要添加事件 |
1.3 现有环境变量
# 已存在于 .env.example
DISCORD_WEBHOOK_URL="" # 第416行
FEISHU_WEBHOOK_URL="" # 第420行
1.4 现有的安全防护机制
- Webhook 签名验证:
stripe.ts使用constructEvent验证签名 - 幂等性保护:
webhook_event表存储已处理事件,防止重复 - 超时保护: Webhook 路由设置 10 秒超时
- 错误隔离: 通知失败不影响支付主流程
2. 目标功能清单
2.1 功能优先级矩阵
| 功能 | 优先级 | MVP必做 | 难度 | 预估代码量 |
|---|---|---|---|---|
| 飞书卡片消息 | P0 | ✅ | 低 | 100行 |
| 飞书签名校验 | P0 | ✅ | 低 | 20行 |
| 多维表格存档 | P1 | ✅ | 中 | 120行 |
| 退款/争议通知 | P1 | ✅ | 低 | 50行 |
| Resend邮件异常 | P2 | ⚠️ | 中 | 100行 |
| 网站监控告警 | P2 | ⚠️ | 中 | 80行 |
| API余额监控 | P3 | ❌ | 高 | 150行 |
2.2 事件类型全量清单
| 事件类型 | 来源 | 通知级别 | 是否@所有人 |
|---|---|---|---|
checkout.completed |
Stripe/Creem | 🟢 成功 | ❌ |
subscription.active |
Stripe/Creem | 🟢 成功 | ❌ |
subscription.paid |
Stripe | 🟡 信息 | ❌ |
subscription.canceled |
Stripe | 🟠 警告 | ❌ |
refund.created |
Stripe | 🟠 警告 | ❌ |
dispute.created |
Stripe | 🔴 紧急 | ✅ |
email.bounced |
Resend | 🔴 紧急 | ❌ |
email.complained |
Resend | 🔴 紧急 | ✅ |
monitor.down |
UptimeKuma | 🔴 紧急 | ✅ |
monitor.up |
UptimeKuma | 🟢 恢复 | ❌ |
api.balance.low |
Cron | 🟠 警告 | ✅ |
3. 环境变量配置
3.1 需要添加到 .env.example 的变量
在 .env.example 的第 420 行 FEISHU_WEBHOOK_URL="" 之后添加:
# -----------------------------------------------------------------------------
# Notification (Feishu) - 飞书通知增强配置
# 文档:https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
# -----------------------------------------------------------------------------
FEISHU_WEBHOOK_URL=""
# 飞书群机器人签名密钥(可选但强烈推荐)
# 获取方式:创建机器人时勾选"签名校验",复制生成的密钥
FEISHU_BOT_SECRET=""
# -----------------------------------------------------------------------------
# Feishu Bitable (飞书多维表格) - 用于存档所有通知记录
# 文档:https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/create
# -----------------------------------------------------------------------------
# 飞书开放平台应用凭证
# 获取地址:https://open.feishu.cn/app → 创建应用 → 凭证与基础信息
FEISHU_APP_ID=""
FEISHU_APP_SECRET=""
# 多维表格配置
# 从多维表格URL获取:https://xxx.feishu.cn/base/{APP_TOKEN}?table={TABLE_ID}
FEISHU_BITABLE_APP_TOKEN=""
FEISHU_BITABLE_TABLE_ID=""
# -----------------------------------------------------------------------------
# Resend Webhook (邮件事件通知)
# 文档:https://resend.com/docs/dashboard/webhooks/introduction
# -----------------------------------------------------------------------------
# Webhook 签名密钥(Resend Dashboard → Webhooks → Signing Secret)
RESEND_WEBHOOK_SECRET=""
# -----------------------------------------------------------------------------
# Uptime Webhook (网站监控告警)
# 用于接收 Uptime Kuma 或其他监控服务的告警
# -----------------------------------------------------------------------------
# 可选:用于验证请求来源的密钥
UPTIME_WEBHOOK_SECRET=""
# -----------------------------------------------------------------------------
# API Balance Monitor (API余额监控 - 可选)
# -----------------------------------------------------------------------------
# OpenAI Admin API Key(需要组织管理员权限)
# 获取:https://platform.openai.com/settings/organization/admin-keys
OPENAI_ADMIN_KEY=""
# 告警阈值(美元,超过此值发送告警)
OPENAI_BALANCE_THRESHOLD="100"
# Anthropic Admin Key(如果使用Claude API)
ANTHROPIC_ADMIN_KEY=""
ANTHROPIC_BALANCE_THRESHOLD="100"
3.2 环境变量说明表
| 变量名 | 必填 | 默认值 | 说明 |
|---|---|---|---|
FEISHU_WEBHOOK_URL |
✅ | - | 飞书群机器人Webhook地址 |
FEISHU_BOT_SECRET |
⚠️ 推荐 | - | 签名密钥,防止URL泄露被滥用 |
FEISHU_APP_ID |
多维表格必填 | - | 飞书应用ID |
FEISHU_APP_SECRET |
多维表格必填 | - | 飞书应用密钥 |
FEISHU_BITABLE_APP_TOKEN |
多维表格必填 | - | 多维表格App Token |
FEISHU_BITABLE_TABLE_ID |
多维表格必填 | - | 多维表格Table ID |
RESEND_WEBHOOK_SECRET |
Resend通知必填 | - | Resend Webhook签名密钥 |
UPTIME_WEBHOOK_SECRET |
⚠️ 推荐 | - | 监控Webhook验证密钥 |
OPENAI_ADMIN_KEY |
余额监控必填 | - | OpenAI Admin API Key |
OPENAI_BALANCE_THRESHOLD |
❌ | 100 |
告警阈值(美元) |
4. 文件结构规划
4.1 新增文件清单
src/
├── notification/
│ ├── feishu.ts # ✏️ 修改:升级为卡片消息
│ ├── feishu-types.ts # 🆕 新增:类型定义
│ ├── feishu-bitable.ts # 🆕 新增:多维表格服务
│ ├── feishu-sign.ts # 🆕 新增:签名工具
│ ├── notification.ts # ✏️ 修改:统一入口升级
│ └── discord.ts # ⏭️ 不变
│
├── app/api/webhooks/
│ ├── stripe/route.ts # ⏭️ 不变
│ ├── creem/route.ts # ⏭️ 不变
│ ├── resend/route.ts # 🆕 新增:邮件事件Webhook
│ └── uptime/route.ts # 🆕 新增:监控告警Webhook
│
├── app/api/cron/
│ ├── housekeeping/route.ts # ⏭️ 不变
│ └── api-balance-monitor/route.ts # 🆕 新增:API余额检查
│
└── payment/provider/
└── stripe.ts # ✏️ 修改:添加退款/争议通知
4.2 文件依赖关系图
feishu-types.ts (类型定义,无依赖)
↓
feishu-sign.ts (签名工具,依赖 crypto)
↓
feishu.ts (卡片消息,依赖 types + sign)
↓
feishu-bitable.ts (多维表格,依赖 types)
↓
notification.ts (统一入口,依赖 feishu + bitable + discord)
↓
stripe.ts / resend/route.ts / uptime/route.ts (调用通知)
5. 第一阶段:飞书通知服务升级
5.1 创建类型定义文件
文件: src/notification/feishu-types.ts
/**
* 飞书通知系统类型定义
*
* @description 定义所有飞书通知相关的类型,包括事件类型、卡片模板、消息载荷等
* @see https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot#f62e72d5
*/
/**
* 通知事件类型枚举
*
* 命名规则:{来源}.{动作}
* - checkout: 结账相关
* - subscription: 订阅相关
* - refund: 退款相关
* - dispute: 争议相关
* - email: 邮件相关
* - monitor: 监控相关
* - api: API相关
*/
export type FeishuEventType =
// 支付成功类
| 'checkout.completed' // 一次性支付成功
| 'subscription.active' // 新订阅激活
| 'subscription.paid' // 订阅续费成功
// 支付异常类
| 'subscription.canceled' // 订阅取消
| 'subscription.past_due' // 订阅逾期
| 'refund.created' // 退款发起
| 'refund.completed' // 退款完成
| 'dispute.created' // 争议/拒付创建
| 'dispute.closed' // 争议关闭
// 邮件类
| 'email.bounced' // 邮件退信
| 'email.complained' // 邮件投诉(严重)
| 'email.delivered' // 邮件送达(调试用)
// 监控类
| 'monitor.down' // 网站宕机
| 'monitor.up' // 网站恢复
// API类
| 'api.balance.low' // API余额不足
| 'api.balance.critical'; // API余额严重不足
/**
* 飞书卡片模板颜色
* @see https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/card-header
*/
export type FeishuCardTemplate =
| 'blue' // 蓝色 - 信息
| 'green' // 绿色 - 成功
| 'orange' // 橙色 - 警告
| 'red' // 红色 - 紧急
| 'purple' // 紫色 - 特殊
| 'grey' // 灰色 - 中性
| 'indigo' // 靛蓝 - 进行中
| 'turquoise' // 青绿 - 完成
| 'yellow' // 黄色 - 注意
| 'carmine' // 洋红 - 重要
| 'wathet'; // 浅蓝 - 轻量信息
/**
* 通知来源
*/
export type NotificationSource =
| 'Stripe'
| 'Creem'
| 'Resend'
| 'UptimeKuma'
| 'UptimeRobot'
| 'System'
| 'Cron';
/**
* 通知级别
*/
export type NotificationLevel =
| 'success' // 成功
| 'info' // 信息
| 'warning' // 警告
| 'error' // 错误
| 'critical'; // 紧急
/**
* 飞书通知载荷
*/
export interface FeishuNotificationPayload {
/** 事件类型 */
eventType: FeishuEventType;
/** 通知来源 */
source: NotificationSource;
/** 通知级别(可选,会根据eventType自动推断) */
level?: NotificationLevel;
/** 客户邮箱或用户名 */
customer?: string;
/** 产品名称 */
product?: string;
/** 金额(已转换为主单位,如美元而非美分) */
amount?: number;
/** 货币代码 */
currency?: string;
/** 订单/会话/发票ID */
orderId?: string;
/** 是否 @所有人(紧急事件自动为true) */
mentionAll?: boolean;
/** 额外的键值对字段 */
extra?: Record<string, string | number | boolean>;
/** 操作按钮 */
actions?: Array<{
text: string;
url: string;
type?: 'primary' | 'default' | 'danger';
}>;
}
/**
* 事件类型 -> 卡片配置映射
*/
export interface EventConfig {
template: FeishuCardTemplate;
icon: string;
level: NotificationLevel;
mentionAll: boolean;
titlePrefix?: string;
}
/**
* 多维表格记录字段
*/
export interface BitableRecordFields {
/** 时间戳(毫秒) */
时间: number;
/** 通知来源 */
来源: string;
/** 事件类型 */
事件类型: string;
/** 通知级别 */
级别: string;
/** 客户邮箱 */
客户邮箱: string;
/** 产品名称 */
产品: string;
/** 金额 */
金额: number;
/** 货币 */
货币: string;
/** 订单ID */
订单ID: string;
/** 处理状态 */
状态: string;
/** 原始数据JSON */
原始数据: string;
}
/**
* 飞书API响应基础结构
*/
export interface FeishuApiResponse {
code: number;
msg: string;
data?: unknown;
}
/**
* 飞书Token响应
*/
export interface FeishuTokenResponse extends FeishuApiResponse {
tenant_access_token?: string;
expire?: number;
}
5.2 创建签名工具文件
文件: src/notification/feishu-sign.ts
/**
* 飞书签名工具
*
* @description 用于生成飞书群机器人消息签名,防止Webhook URL泄露后被滥用
* @see https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot#f62e72d5
*/
import crypto from 'crypto';
/**
* 生成飞书消息签名
*
* @description 签名算法:把timestamp + "\n" + secret 作为 HMAC-SHA256 的签名字符串,
* 把 HMAC-SHA256 的签名结果转为 base64 编码
*
* @param timestamp - 时间戳(秒级)
* @param secret - 签名密钥
* @returns base64编码的签名字符串
*
* @example
* ```typescript
* const timestamp = Math.floor(Date.now() / 1000).toString();
* const sign = generateFeishuSign(timestamp, process.env.FEISHU_BOT_SECRET);
* ```
*/
export function generateFeishuSign(timestamp: string, secret: string): string {
// 构建签名字符串:timestamp + 换行符 + secret
const stringToSign = `${timestamp}\n${secret}`;
// 使用 HMAC-SHA256 计算签名
const hmac = crypto.createHmac('sha256', stringToSign);
// 返回 base64 编码的结果
return hmac.digest('base64');
}
/**
* 为飞书消息添加签名字段
*
* @description 如果配置了FEISHU_BOT_SECRET,自动添加timestamp和sign字段
*
* @param message - 原始消息对象
* @returns 添加了签名的消息对象
*
* @example
* ```typescript
* const message = { msg_type: 'text', content: { text: 'Hello' } };
* const signedMessage = addSignatureToMessage(message);
* // 如果配置了密钥,signedMessage 将包含 timestamp 和 sign 字段
* ```
*/
export function addSignatureToMessage<T extends Record<string, unknown>>(
message: T
): T & { timestamp?: string; sign?: string } {
const secret = process.env.FEISHU_BOT_SECRET;
// 如果没有配置签名密钥,直接返回原消息
if (!secret) {
return message;
}
const timestamp = Math.floor(Date.now() / 1000).toString();
const sign = generateFeishuSign(timestamp, secret);
return {
...message,
timestamp,
sign,
};
}
/**
* 验证飞书Webhook回调签名(用于接收飞书回调的场景)
*
* @description 这个函数用于验证从飞书发来的回调请求,确保请求来自飞书
* @note 当前项目主要是发送消息到飞书,此函数备用
*
* @param timestamp - 请求头中的时间戳
* @param nonce - 请求头中的随机数
* @param body - 请求体
* @param signature - 请求头中的签名
* @param encryptKey - 应用的 Encrypt Key
* @returns 签名是否有效
*/
export function verifyFeishuCallback(
timestamp: string,
nonce: string,
body: string,
signature: string,
encryptKey: string
): boolean {
const stringToSign = timestamp + nonce + encryptKey + body;
const computedSignature = crypto
.createHash('sha256')
.update(stringToSign)
.digest('hex');
return computedSignature === signature;
}
5.3 升级飞书通知主文件
文件: src/notification/feishu.ts(完全重写)
/**
* 飞书通知服务
*
* @description 发送业务通知到飞书群机器人,支持卡片消息、签名校验
* @see https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
*/
import { logger } from '@/lib/safe-logger';
import { addSignatureToMessage } from './feishu-sign';
import type {
EventConfig,
FeishuCardTemplate,
FeishuEventType,
FeishuNotificationPayload,
NotificationLevel,
} from './feishu-types';
// ============================================================================
// 配置常量
// ============================================================================
/**
* 事件类型 -> 卡片配置映射表
*
* @description 定义每种事件类型对应的卡片颜色、图标、级别和是否@所有人
*/
const EVENT_CONFIG_MAP: Record<FeishuEventType, EventConfig> = {
// 支付成功类 - 绿色
'checkout.completed': {
template: 'green',
icon: '✅',
level: 'success',
mentionAll: false,
titlePrefix: '支付成功',
},
'subscription.active': {
template: 'green',
icon: '🎉',
level: 'success',
mentionAll: false,
titlePrefix: '新订阅',
},
'subscription.paid': {
template: 'green',
icon: '💵',
level: 'info',
mentionAll: false,
titlePrefix: '续费成功',
},
// 支付异常类 - 橙色/红色
'subscription.canceled': {
template: 'orange',
icon: '❌',
level: 'warning',
mentionAll: false,
titlePrefix: '订阅取消',
},
'subscription.past_due': {
template: 'orange',
icon: '⏰',
level: 'warning',
mentionAll: false,
titlePrefix: '订阅逾期',
},
'refund.created': {
template: 'orange',
icon: '↩️',
level: 'warning',
mentionAll: false,
titlePrefix: '退款申请',
},
'refund.completed': {
template: 'orange',
icon: '💸',
level: 'info',
mentionAll: false,
titlePrefix: '退款完成',
},
'dispute.created': {
template: 'red',
icon: '🚨',
level: 'critical',
mentionAll: true,
titlePrefix: '争议/拒付',
},
'dispute.closed': {
template: 'grey',
icon: '📋',
level: 'info',
mentionAll: false,
titlePrefix: '争议关闭',
},
// 邮件类 - 红色
'email.bounced': {
template: 'red',
icon: '📧',
level: 'error',
mentionAll: false,
titlePrefix: '邮件退信',
},
'email.complained': {
template: 'red',
icon: '🚫',
level: 'critical',
mentionAll: true,
titlePrefix: '邮件投诉',
},
'email.delivered': {
template: 'grey',
icon: '📬',
level: 'info',
mentionAll: false,
titlePrefix: '邮件送达',
},
// 监控类
'monitor.down': {
template: 'red',
icon: '🔴',
level: 'critical',
mentionAll: true,
titlePrefix: '网站宕机',
},
'monitor.up': {
template: 'green',
icon: '🟢',
level: 'success',
mentionAll: false,
titlePrefix: '网站恢复',
},
// API类
'api.balance.low': {
template: 'orange',
icon: '⚠️',
level: 'warning',
mentionAll: true,
titlePrefix: 'API余额告警',
},
'api.balance.critical': {
template: 'red',
icon: '🔴',
level: 'critical',
mentionAll: true,
titlePrefix: 'API余额严重不足',
},
};
/**
* 获取事件配置,提供默认值
*/
function getEventConfig(eventType: FeishuEventType): EventConfig {
return (
EVENT_CONFIG_MAP[eventType] ?? {
template: 'blue',
icon: '📬',
level: 'info' as NotificationLevel,
mentionAll: false,
}
);
}
// ============================================================================
// 工具函数
// ============================================================================
/**
* 格式化货币金额
*
* @param amount - 金额数值
* @param currency - 货币代码(默认USD)
* @returns 格式化后的金额字符串
*/
function formatCurrency(amount: number, currency = 'USD'): string {
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
}).format(amount);
} catch {
// 如果货币代码无效,回退到简单格式
return `${currency.toUpperCase()} ${amount.toFixed(2)}`;
}
}
/**
* 获取北京时间字符串
*/
function getBeijingTime(): string {
return new Date().toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
// ============================================================================
// 卡片构建
// ============================================================================
/**
* 构建飞书卡片消息
*
* @description 根据通知载荷构建飞书交互式卡片消息
* @see https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/card-structure/card-content
*/
function buildFeishuCard(payload: FeishuNotificationPayload): Record<string, unknown> {
const config = getEventConfig(payload.eventType);
const {
eventType,
source,
customer,
product,
amount,
currency = 'USD',
orderId,
extra,
actions,
mentionAll,
} = payload;
// 构建标题
const titleText = config.titlePrefix
? `${config.icon} ${config.titlePrefix}`
: `${config.icon} ${source}: ${eventType}`;
// 构建内容字段
const contentParts: string[] = [];
if (customer) {
contentParts.push(`**客户**:${customer}`);
}
if (product) {
contentParts.push(`**产品**:${product}`);
}
if (amount !== undefined) {
contentParts.push(`**金额**:${formatCurrency(amount, currency)}`);
}
if (orderId) {
contentParts.push(`**订单号**:\`${orderId}\``);
}
// 添加额外字段
if (extra) {
for (const [key, value] of Object.entries(extra)) {
if (value !== undefined && value !== null && value !== '') {
const displayValue = typeof value === 'boolean'
? (value ? '是' : '否')
: String(value);
contentParts.push(`**${key}**:${displayValue}`);
}
}
}
// 构建卡片元素
const elements: Array<Record<string, unknown>> = [];
// 主内容区
if (contentParts.length > 0) {
elements.push({
tag: 'div',
text: {
tag: 'lark_md',
content: contentParts.join('\n'),
},
});
}
// @所有人(紧急事件或明确指定)
const shouldMentionAll = mentionAll ?? config.mentionAll;
if (shouldMentionAll) {
elements.push({
tag: 'div',
text: {
tag: 'lark_md',
content: '<at user_id="all">所有人</at>',
},
});
}
// 分割线
elements.push({ tag: 'hr' });
// 操作按钮
if (actions && actions.length > 0) {
elements.push({
tag: 'action',
actions: actions.map((action) => ({
tag: 'button',
text: {
tag: 'plain_text',
content: action.text,
},
url: action.url,
type: action.type ?? 'default',
})),
});
}
// 时间戳和来源
elements.push({
tag: 'note',
elements: [
{
tag: 'plain_text',
content: `⏰ ${getBeijingTime()} | 来源: ${source}`,
},
],
});
return {
msg_type: 'interactive',
card: {
config: {
wide_screen_mode: true,
enable_forward: true,
},
header: {
title: {
tag: 'plain_text',
content: titleText,
},
template: config.template,
},
elements,
},
};
}
// ============================================================================
// 发送函数
// ============================================================================
/**
* 发送飞书卡片通知(新版API)
*
* @description 发送格式化的卡片消息到飞书群机器人
*
* @param payload - 通知载荷
* @returns Promise<void> - 不抛出错误,失败时仅记录日志
*
* @example
* ```typescript
* await sendFeishuNotification({
* eventType: 'checkout.completed',
* source: 'Stripe',
* customer: '[email protected]',
* amount: 99,
* currency: 'USD',
* orderId: 'cs_xxx',
* });
* ```
*/
export async function sendFeishuNotification(
payload: FeishuNotificationPayload
): Promise<void> {
try {
const webhookUrl = process.env.FEISHU_WEBHOOK_URL;
if (!webhookUrl) {
logger.warn(
'FEISHU_WEBHOOK_URL is not set, skipping Feishu notification'
);
return;
}
// 构建卡片消息
const cardMessage = buildFeishuCard(payload);
// 添加签名(如果配置了密钥)
const signedMessage = addSignatureToMessage(cardMessage);
// 发送请求
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(signedMessage),
});
// 解析响应
const result = await response.json();
if (!response.ok || result.code !== 0) {
logger.error('Failed to send Feishu notification:', {
status: response.status,
code: result.code,
msg: result.msg,
eventType: payload.eventType,
});
} else {
logger.log(
`Successfully sent Feishu notification: ${payload.eventType} from ${payload.source}`
);
}
} catch (error) {
logger.error('Failed to send Feishu notification:', error);
// 不抛出错误,避免中断主业务流程
}
}
// ============================================================================
// 向后兼容接口
// ============================================================================
/**
* 发送简单文本消息到飞书(向后兼容)
*
* @deprecated 请使用 sendFeishuNotification 代替
*
* @description 保留此函数以确保现有代码不会报错,内部转换为卡片消息
*/
export async function sendMessageToFeishu(
sessionId: string,
customerId: string,
userName: string,
amount: number
): Promise<void> {
return sendFeishuNotification({
eventType: 'checkout.completed',
source: 'Stripe',
customer: userName,
amount,
currency: 'USD',
orderId: sessionId,
extra: {
'客户ID': customerId,
},
});
}
5.4 升级统一通知入口
文件: src/notification/notification.ts(完全重写)
/**
* 统一通知服务入口
*
* @description 提供统一的通知发送接口,自动分发到多个渠道(Discord、飞书、多维表格)
*/
import { logger } from '@/lib/safe-logger';
import { sendMessageToDiscord } from './discord';
import { sendFeishuNotification, sendMessageToFeishu } from './feishu';
import { addBitableRecord } from './feishu-bitable';
import type { FeishuNotificationPayload } from './feishu-types';
// ============================================================================
// 核心通知函数
// ============================================================================
/**
* 发送业务通知(新版API)
*
* @description 统一的通知发送入口,自动分发到:
* - 飞书群机器人(卡片消息)
* - 飞书多维表格(历史存档)
* - Discord(仅成功类事件)
*
* @param payload - 通知载荷
* @returns Promise<void>
*
* @example
* ```typescript
* // 支付成功通知
* await sendBusinessNotification({
* eventType: 'checkout.completed',
* source: 'Stripe',
* customer: '[email protected]',
* product: 'Pro Plan',
* amount: 99,
* currency: 'USD',
* orderId: 'cs_xxx',
* });
*
* // 争议告警(自动@所有人)
* await sendBusinessNotification({
* eventType: 'dispute.created',
* source: 'Stripe',
* customer: '[email protected]',
* amount: 99,
* orderId: 'dp_xxx',
* extra: { '争议原因': 'fraudulent' },
* });
* ```
*/
export async function sendBusinessNotification(
payload: FeishuNotificationPayload
): Promise<void> {
logger.log('sendBusinessNotification:', {
eventType: payload.eventType,
source: payload.source,
customer: payload.customer,
});
// 判断是否需要发送Discord通知(仅成功类事件)
const discordEnabledEvents = [
'checkout.completed',
'subscription.active',
'subscription.paid',
];
const shouldSendDiscord = discordEnabledEvents.includes(payload.eventType);
// 并行发送到多个渠道,使用 Promise.allSettled 确保一个失败不影响其他
const results = await Promise.allSettled([
// 1. 飞书卡片消息(主要通知渠道)
sendFeishuNotification(payload),
// 2. 飞书多维表格存档
addBitableRecord(payload),
// 3. Discord通知(仅特定事件)
shouldSendDiscord
? sendMessageToDiscord(
payload.orderId ?? '',
payload.extra?.['客户ID']?.toString() ?? '',
payload.customer ?? 'Unknown',
payload.amount ?? 0
)
: Promise.resolve(),
]);
// 记录失败的渠道(不影响主流程)
results.forEach((result, index) => {
if (result.status === 'rejected') {
const channels = ['Feishu', 'Bitable', 'Discord'];
logger.warn(`Notification to ${channels[index]} failed:`, result.reason);
}
});
}
// ============================================================================
// 快捷函数
// ============================================================================
/**
* 发送支付成功通知
*/
export async function notifyPaymentSuccess(params: {
source: 'Stripe' | 'Creem';
customer: string;
product?: string;
amount: number;
currency?: string;
orderId: string;
customerId?: string;
}): Promise<void> {
return sendBusinessNotification({
eventType: 'checkout.completed',
source: params.source,
customer: params.customer,
product: params.product,
amount: params.amount,
currency: params.currency ?? 'USD',
orderId: params.orderId,
extra: params.customerId ? { '客户ID': params.customerId } : undefined,
});
}
/**
* 发送订阅取消通知
*/
export async function notifySubscriptionCanceled(params: {
source: 'Stripe' | 'Creem';
customer: string;
product?: string;
orderId: string;
reason?: string;
}): Promise<void> {
return sendBusinessNotification({
eventType: 'subscription.canceled',
source: params.source,
customer: params.customer,
product: params.product,
orderId: params.orderId,
extra: params.reason ? { '取消原因': params.reason } : undefined,
});
}
/**
* 发送退款通知
*/
export async function notifyRefund(params: {
source: 'Stripe' | 'Creem';
customer: string;
amount: number;
currency?: string;
orderId: string;
reason?: string;
}): Promise<void> {
return sendBusinessNotification({
eventType: 'refund.created',
source: params.source,
customer: params.customer,
amount: params.amount,
currency: params.currency ?? 'USD',
orderId: params.orderId,
extra: params.reason ? { '退款原因': params.reason } : undefined,
});
}
/**
* 发送争议告警
*/
export async function notifyDispute(params: {
source: 'Stripe';
customer: string;
amount: number;
currency?: string;
orderId: string;
reason?: string;
dashboardUrl?: string;
}): Promise<void> {
return sendBusinessNotification({
eventType: 'dispute.created',
source: params.source,
customer: params.customer,
amount: params.amount,
currency: params.currency ?? 'USD',
orderId: params.orderId,
extra: params.reason ? { '争议原因': params.reason } : undefined,
actions: params.dashboardUrl
? [{ text: '查看详情', url: params.dashboardUrl, type: 'primary' }]
: undefined,
mentionAll: true,
});
}
/**
* 发送监控告警
*/
export async function notifyMonitorStatus(params: {
source: 'UptimeKuma' | 'UptimeRobot';
isDown: boolean;
monitorName: string;
monitorUrl?: string;
duration?: string;
}): Promise<void> {
return sendBusinessNotification({
eventType: params.isDown ? 'monitor.down' : 'monitor.up',
source: params.source,
extra: {
'监控项': params.monitorName,
...(params.monitorUrl && { 'URL': params.monitorUrl }),
...(params.duration && { '持续时间': params.duration }),
},
mentionAll: params.isDown,
});
}
/**
* 发送API余额告警
*/
export async function notifyApiBalance(params: {
apiName: string;
currentBalance: number;
threshold: number;
currency?: string;
isCritical?: boolean;
}): Promise<void> {
return sendBusinessNotification({
eventType: params.isCritical ? 'api.balance.critical' : 'api.balance.low',
source: 'Cron',
extra: {
'API': params.apiName,
'当前余额': `${params.currency ?? '$'}${params.currentBalance.toFixed(2)}`,
'告警阈值': `${params.currency ?? '$'}${params.threshold.toFixed(2)}`,
},
mentionAll: true,
});
}
// ============================================================================
// 向后兼容接口
// ============================================================================
/**
* 发送通知(向后兼容旧版API)
*
* @deprecated 请使用 sendBusinessNotification 或快捷函数代替
*/
export async function sendNotification(
sessionId: string,
customerId: string,
userName: string,
amount: number
): Promise<void> {
logger.log('sendNotification (legacy)', sessionId, customerId, userName, amount);
// 调用新版API
return sendBusinessNotification({
eventType: 'checkout.completed',
source: 'Stripe',
customer: userName,
amount,
currency: 'USD',
orderId: sessionId,
extra: { '客户ID': customerId },
});
}
6. 第二阶段:多维表格存档
6.1 创建多维表格服务
文件: src/notification/feishu-bitable.ts
/**
* 飞书多维表格服务
*
* @description 将通知记录写入飞书多维表格,用于历史存档和统计分析
* @see https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/create
*/
import { logger } from '@/lib/safe-logger';
import type {
BitableRecordFields,
FeishuNotificationPayload,
FeishuTokenResponse,
} from './feishu-types';
// ============================================================================
// Token 缓存
// ============================================================================
/**
* Token缓存结构
*/
interface TokenCache {
token: string;
expiresAt: number;
}
/**
* 全局Token缓存
* @description 飞书token有效期2小时,提前5分钟刷新
*/
let tokenCache: TokenCache | null = null;
/**
* Token刷新提前量(毫秒)
*/
const TOKEN_REFRESH_BUFFER = 5 * 60 * 1000; // 5分钟
// ============================================================================
// Token 获取
// ============================================================================
/**
* 获取飞书 tenant_access_token
*
* @description 使用应用凭证获取访问令牌,带缓存机制
* @see https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal
*
* @returns token字符串,获取失败返回null
*/
async function getFeishuToken(): Promise<string | null> {
const appId = process.env.FEISHU_APP_ID;
const appSecret = process.env.FEISHU_APP_SECRET;
// 检查配置
if (!appId || !appSecret) {
// 静默跳过,多维表格是可选功能
return null;
}
// 检查缓存是否有效
const now = Date.now();
if (tokenCache && now < tokenCache.expiresAt - TOKEN_REFRESH_BUFFER) {
return tokenCache.token;
}
try {
const response = 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: FeishuTokenResponse = await response.json();
if (data.code !== 0 || !data.tenant_access_token) {
logger.error('Failed to get Feishu token:', {
code: data.code,
msg: data.msg,
});
return null;
}
// 更新缓存
tokenCache = {
token: data.tenant_access_token,
expiresAt: now + (data.expire ?? 7200) * 1000,
};
logger.log('Feishu token refreshed successfully');
return tokenCache.token;
} catch (error) {
logger.error('Failed to get Feishu token:', error);
return null;
}
}
// ============================================================================
// 记录写入
// ============================================================================
/**
* 通知级别 -> 中文映射
*/
const LEVEL_MAP: Record<string, string> = {
success: '🟢 正常',
info: '🔵 信息',
warning: '🟡 警告',
error: '🔴 错误',
critical: '🔴 紧急',
};
/**
* 从事件类型推断通知级别
*/
function inferLevel(eventType: string): string {
if (eventType.includes('completed') || eventType.includes('active') || eventType.includes('.up')) {
return LEVEL_MAP.success;
}
if (eventType.includes('canceled') || eventType.includes('refund') || eventType.includes('past_due')) {
return LEVEL_MAP.warning;
}
if (eventType.includes('dispute') || eventType.includes('complained') || eventType.includes('.down') || eventType.includes('critical')) {
return LEVEL_MAP.critical;
}
if (eventType.includes('bounced')) {
return LEVEL_MAP.error;
}
return LEVEL_MAP.info;
}
/**
* 将通知记录写入飞书多维表格
*
* @description 异步写入,失败不影响主业务流程
*
* @param payload - 通知载荷
* @returns Promise<void>
*
* @example
* ```typescript
* await addBitableRecord({
* eventType: 'checkout.completed',
* source: 'Stripe',
* customer: '[email protected]',
* amount: 99,
* currency: 'USD',
* orderId: 'cs_xxx',
* });
* ```
*/
export async function addBitableRecord(
payload: FeishuNotificationPayload
): Promise<void> {
const appToken = process.env.FEISHU_BITABLE_APP_TOKEN;
const tableId = process.env.FEISHU_BITABLE_TABLE_ID;
// 检查配置(可选功能)
if (!appToken || !tableId) {
// 静默跳过
return;
}
// 获取token
const token = await getFeishuToken();
if (!token) {
return;
}
try {
// 构建字段数据
const fields: BitableRecordFields = {
时间: Date.now(),
来源: payload.source,
事件类型: payload.eventType,
级别: inferLevel(payload.eventType),
客户邮箱: payload.customer ?? '',
产品: payload.product ?? '',
金额: payload.amount ?? 0,
货币: payload.currency ?? 'USD',
订单ID: payload.orderId ?? '',
状态: '待处理',
原始数据: JSON.stringify(payload, null, 2),
};
// 发送请求
const response = await fetch(
`https://open.feishu.cn/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ fields }),
}
);
const data = await response.json();
if (data.code !== 0) {
logger.error('Failed to write to Feishu bitable:', {
code: data.code,
msg: data.msg,
});
} else {
logger.log('Successfully wrote to Feishu bitable:', payload.eventType);
}
} catch (error) {
logger.error('Failed to write to Feishu bitable:', error);
// 不抛出错误
}
}
// ============================================================================
// 多维表格配置指南
// ============================================================================
/**
* 多维表格字段配置说明
*
* 在飞书多维表格中创建以下字段:
*
* | 字段名 | 类型 | 说明 |
* |--------|------|------|
* | 时间 | 日期 | 通知接收时间 |
* | 来源 | 单选 | Stripe / Creem / Resend / UptimeKuma / System / Cron |
* | 事件类型 | 单选 | checkout.completed / subscription.active / ... |
* | 级别 | 单选 | 🟢 正常 / 🔵 信息 / 🟡 警告 / 🔴 错误 / 🔴 紧急 |
* | 客户邮箱 | 文本 | 客户邮箱或用户名 |
* | 产品 | 文本 | 产品名称 |
* | 金额 | 数字 | 交易金额 |
* | 货币 | 单选 | USD / EUR / CNY / ... |
* | 订单ID | 文本 | 订单/会话/发票ID |
* | 状态 | 单选 | 待处理 / 已处理 / 已忽略 |
* | 原始数据 | 多行文本 | JSON格式的完整载荷 |
*
* 视图配置建议:
* 1. 今日通知:筛选「时间 = 今天」,按时间倒序
* 2. 待处理:筛选「状态 = 待处理」
* 3. 紧急告警:筛选「级别 = 🔴 紧急」
* 4. 收入统计:筛选支付成功事件,汇总金额
* 5. 异常事件:筛选退款/争议/投诉事件
*/
6.2 飞书多维表格配置步骤
步骤1:创建飞书开放平台应用
- 访问 飞书开放平台
- 点击「创建企业自建应用」
- 填写应用名称(如:业务通知服务)
- 获取
App ID和App Secret - 在「权限管理」中添加:
-
bitable:app- 多维表格应用权限 -bitable:app:readonly- 多维表格只读权限(可选)
步骤2:创建多维表格
- 在飞书中创建新的多维表格
- 添加以下字段:
| 字段名 | 字段类型 | 配置说明 |
|---|---|---|
| 时间 | 日期 | 日期格式:年-月-日 时:分:秒 |
| 来源 | 单选 | 选项:Stripe, Creem, Resend, UptimeKuma, System, Cron |
| 事件类型 | 文本 | - |
| 级别 | 单选 | 选项:🟢 正常, 🔵 信息, 🟡 警告, 🔴 错误, 🔴 紧急 |
| 客户邮箱 | 文本 | - |
| 产品 | 文本 | - |
| 金额 | 数字 | 小数位数:2 |
| 货币 | 单选 | 选项:USD, EUR, CNY, GBP |
| 订单ID | 文本 | - |
| 状态 | 单选 | 选项:待处理, 已处理, 已忽略 |
| 原始数据 | 多行文本 | - |
步骤3:获取表格标识符
从多维表格URL中获取:
https://xxx.feishu.cn/base/BASExxxxxx?table=tblYYYYYY
↑ APP_TOKEN ↑ TABLE_ID
步骤4:添加应用为协作者
- 打开多维表格
- 点击右上角「分享」
- 添加你创建的应用为协作者(可编辑权限)
7. 第三阶段:扩展事件类型
7.1 修改 Stripe 支付提供者
文件: src/payment/provider/stripe.ts
需要在以下位置添加通知调用:
7.1.1 订阅取消通知
找到 onDeleteSubscription 函数(约第 2400 行),在成功处理后添加通知:
// 在 onDeleteSubscription 函数末尾,return 之前添加:
// 发送订阅取消通知
try {
const { notifySubscriptionCanceled } = await import('@/notification/notification');
await notifySubscriptionCanceled({
source: 'Stripe',
customer: subscription.customer_email ?? subscription.customer?.toString() ?? 'Unknown',
product: subscription.items.data[0]?.price?.product?.toString(),
orderId: subscription.id,
reason: subscription.cancellation_details?.reason ?? undefined,
});
} catch (notifyError) {
logger.warn('Failed to send subscription canceled notification:', notifyError);
}
7.1.2 监听退款和争议事件
找到 handleWebhookEvent 函数中的事件分发逻辑(约第 690 行),添加新事件处理:
// 在 switch 语句中添加新的 case:
case 'charge.refunded': {
const charge = event.data.object as Stripe.Charge;
const { notifyRefund } = await import('@/notification/notification');
await notifyRefund({
source: 'Stripe',
customer: charge.billing_details?.email ?? 'Unknown',
amount: (charge.amount_refunded ?? 0) / 100,
currency: charge.currency?.toUpperCase(),
orderId: charge.id,
reason: charge.refunds?.data[0]?.reason ?? undefined,
});
break;
}
case 'charge.dispute.created': {
const dispute = event.data.object as Stripe.Dispute;
const { notifyDispute } = await import('@/notification/notification');
await notifyDispute({
source: 'Stripe',
customer: dispute.evidence?.customer_email_address ?? 'Unknown',
amount: dispute.amount / 100,
currency: dispute.currency?.toUpperCase(),
orderId: dispute.id,
reason: dispute.reason,
dashboardUrl: `https://dashboard.stripe.com/disputes/${dispute.id}`,
});
break;
}
7.2 更新 Stripe Webhook 监听事件
在 Stripe Dashboard 中添加以下事件监听:
- 登录 Stripe Dashboard
- 进入 Developers → Webhooks
- 编辑现有 Webhook 端点
- 添加事件:
-
charge.refunded-charge.dispute.created-charge.dispute.closed
8. 第四阶段:邮件异常通知
8.1 创建 Resend Webhook 路由
文件: src/app/api/webhooks/resend/route.ts
/**
* Resend Webhook 处理器
*
* @description 接收 Resend 邮件事件,将异常事件转发到飞书
* @see https://resend.com/docs/dashboard/webhooks/event-types
*/
import { logger } from '@/lib/safe-logger';
import { sendBusinessNotification } from '@/notification/notification';
import type { FeishuEventType } from '@/notification/feishu-types';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
export const runtime = 'nodejs';
/**
* Resend 事件类型
*/
type ResendEventType =
| 'email.sent'
| 'email.delivered'
| 'email.delivery_delayed'
| 'email.complained'
| 'email.bounced'
| 'email.opened'
| 'email.clicked';
/**
* Resend Webhook 载荷
*/
interface ResendWebhookPayload {
type: ResendEventType;
created_at: string;
data: {
email_id: string;
from: string;
to: string[];
subject: string;
// bounce 相关
bounce?: {
message: string;
};
// complaint 相关
complaint?: {
feedback_id: string;
};
};
}
/**
* 需要通知的事件类型
*/
const NOTIFY_EVENTS: ResendEventType[] = [
'email.bounced',
'email.complained',
'email.delivery_delayed',
];
/**
* Resend事件 -> 飞书事件映射
*/
const EVENT_MAP: Partial<Record<ResendEventType, FeishuEventType>> = {
'email.bounced': 'email.bounced',
'email.complained': 'email.complained',
'email.delivered': 'email.delivered',
};
/**
* 验证 Resend Webhook 签名
*
* @see https://resend.com/docs/dashboard/webhooks/secure-your-webhooks
*/
function verifyResendSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
export async function POST(req: NextRequest): Promise<NextResponse> {
try {
const headersList = await headers();
const signature = headersList.get('svix-signature') ?? '';
const timestamp = headersList.get('svix-timestamp') ?? '';
const webhookId = headersList.get('svix-id') ?? '';
const payload = await req.text();
// 验证签名(如果配置了密钥)
const secret = process.env.RESEND_WEBHOOK_SECRET;
if (secret) {
// Resend 使用 Svix,签名格式为 "v1,<signature>"
const signatureParts = signature.split(',');
const v1Signature = signatureParts.find(s => s.startsWith('v1,'))?.slice(3) ?? signatureParts[1];
const signedContent = `${webhookId}.${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret.replace('whsec_', ''))
.update(signedContent)
.digest('base64');
if (v1Signature !== expectedSignature) {
logger.warn('Resend webhook signature verification failed');
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
}
const event: ResendWebhookPayload = JSON.parse(payload);
logger.log('Resend webhook received:', {
type: event.type,
to: event.data.to,
subject: event.data.subject,
});
// 只处理需要通知的事件
if (!NOTIFY_EVENTS.includes(event.type)) {
return NextResponse.json({ received: true, skipped: true });
}
// 发送通知
const feishuEventType = EVENT_MAP[event.type];
if (feishuEventType) {
await sendBusinessNotification({
eventType: feishuEventType,
source: 'Resend',
customer: event.data.to.join(', '),
extra: {
'邮件主题': event.data.subject,
'发件人': event.data.from,
...(event.data.bounce && { '退信原因': event.data.bounce.message }),
'邮件ID': event.data.email_id,
},
});
}
return NextResponse.json({ received: true });
} catch (error) {
logger.error('Resend webhook processing failed:', error);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
);
}
}
8.2 配置 Resend Webhook
- 登录 Resend Dashboard
- 点击「Add Webhook」
- 配置:
- Endpoint URL:
https://你的域名/api/webhooks/resend- Events: 选择email.bounced,email.complained,email.delivery_delayed - 复制「Signing Secret」到环境变量
RESEND_WEBHOOK_SECRET
9. 第五阶段:网站监控告警
9.1 创建 Uptime Webhook 路由
文件: src/app/api/webhooks/uptime/route.ts
/**
* Uptime 监控 Webhook 处理器
*
* @description 接收 Uptime Kuma / UptimeRobot 等监控服务的告警,转发到飞书
*/
import { logger } from '@/lib/safe-logger';
import { notifyMonitorStatus } from '@/notification/notification';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
/**
* Uptime Kuma Webhook 载荷
* @see https://github.com/louislam/uptime-kuma/wiki/API
*/
interface UptimeKumaPayload {
heartbeat?: {
status: 0 | 1; // 0 = down, 1 = up
msg?: string;
duration?: number;
};
monitor?: {
name: string;
url?: string;
};
msg?: string;
}
/**
* UptimeRobot Webhook 载荷
* @see https://uptimerobot.com/api
*/
interface UptimeRobotPayload {
alertType: 'down' | 'up';
alertTypeFriendlyName?: string;
monitorFriendlyName: string;
monitorURL?: string;
alertDuration?: string;
}
export async function POST(req: NextRequest): Promise<NextResponse> {
try {
const headersList = await headers();
// 验证密钥(可选)
const secret = process.env.UPTIME_WEBHOOK_SECRET;
if (secret) {
const authHeader = headersList.get('authorization');
const providedSecret = headersList.get('x-uptime-secret');
if (authHeader !== `Bearer ${secret}` && providedSecret !== secret) {
logger.warn('Uptime webhook authorization failed');
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
const payload = await req.json();
logger.log('Uptime webhook received:', payload);
// 检测是 Uptime Kuma 还是 UptimeRobot 格式
if ('heartbeat' in payload || 'monitor' in payload) {
// Uptime Kuma 格式
const data = payload as UptimeKumaPayload;
const isDown = data.heartbeat?.status === 0;
await notifyMonitorStatus({
source: 'UptimeKuma',
isDown,
monitorName: data.monitor?.name ?? 'Unknown',
monitorUrl: data.monitor?.url,
duration: data.heartbeat?.duration ? `${data.heartbeat.duration}ms` : undefined,
});
} else if ('alertType' in payload) {
// UptimeRobot 格式
const data = payload as UptimeRobotPayload;
const isDown = data.alertType === 'down';
await notifyMonitorStatus({
source: 'UptimeRobot',
isDown,
monitorName: data.monitorFriendlyName,
monitorUrl: data.monitorURL,
duration: data.alertDuration,
});
} else {
logger.warn('Unknown uptime webhook format:', payload);
}
return NextResponse.json({ received: true });
} catch (error) {
logger.error('Uptime webhook processing failed:', error);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
);
}
}
9.2 配置 Uptime Kuma
推荐: Uptime Kuma 内置飞书通知支持,可以直接使用,无需此Webhook
如果选择使用Webhook方式:
- 在 Uptime Kuma 添加监控项
- 设置通知:
- 通知类型: Webhook
- URL:
https://你的域名/api/webhooks/uptime- 请求方法: POST - Headers:x-uptime-secret: 你的密钥
10. 第六阶段:API余额监控
10.1 创建 Cron 任务
文件: src/app/api/cron/api-balance-monitor/route.ts
/**
* API 余额监控 Cron 任务
*
* @description 定期检查 OpenAI/Anthropic 等 API 的消费余额,超过阈值发送告警
* @schedule 建议每小时执行一次
*/
import { logger } from '@/lib/safe-logger';
import { notifyApiBalance } from '@/notification/notification';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const maxDuration = 60;
/**
* API 检查结果
*/
interface ApiCheckResult {
api: string;
currentUsage: number;
threshold: number;
exceeded: boolean;
error?: string;
}
/**
* 检查 OpenAI 当月消费
*
* @see https://platform.openai.com/docs/api-reference/usage
*/
async function checkOpenAI(): Promise<ApiCheckResult | null> {
const adminKey = process.env.OPENAI_ADMIN_KEY;
const threshold = Number.parseFloat(process.env.OPENAI_BALANCE_THRESHOLD ?? '100');
if (!adminKey) {
return null;
}
try {
// 获取当月第一天
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const startTime = Math.floor(startOfMonth.getTime() / 1000);
const response = await fetch(
`https://api.openai.com/v1/organization/costs?start_time=${startTime}`,
{
headers: {
Authorization: `Bearer ${adminKey}`,
},
}
);
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status}`);
}
const data = await response.json();
const totalUsage = data.data?.reduce(
(sum: number, item: { cost_usd?: string | number }) =>
sum + Number.parseFloat(String(item.cost_usd ?? 0)),
0
) ?? 0;
return {
api: 'OpenAI',
currentUsage: totalUsage,
threshold,
exceeded: totalUsage > threshold,
};
} catch (error) {
logger.error('OpenAI balance check failed:', error);
return {
api: 'OpenAI',
currentUsage: 0,
threshold,
exceeded: false,
error: String(error),
};
}
}
/**
* 检查 Anthropic 当月消费
*
* @see https://docs.anthropic.com/en/api/admin-api
*/
async function checkAnthropic(): Promise<ApiCheckResult | null> {
const adminKey = process.env.ANTHROPIC_ADMIN_KEY;
const threshold = Number.parseFloat(process.env.ANTHROPIC_BALANCE_THRESHOLD ?? '100');
if (!adminKey) {
return null;
}
try {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const startDate = startOfMonth.toISOString().split('T')[0];
const endDate = now.toISOString().split('T')[0];
const response = await fetch(
`https://api.anthropic.com/v1/organizations/cost_report?starting_at=${startDate}T00:00:00Z&ending_at=${endDate}T23:59:59Z`,
{
headers: {
'anthropic-version': '2023-06-01',
'x-api-key': adminKey,
},
}
);
if (!response.ok) {
throw new Error(`Anthropic API error: ${response.status}`);
}
const data = await response.json();
// Anthropic 返回的是美分
const totalUsage = (data.data?.reduce(
(sum: number, item: { cost?: number }) => sum + (item.cost ?? 0),
0
) ?? 0) / 100;
return {
api: 'Anthropic',
currentUsage: totalUsage,
threshold,
exceeded: totalUsage > threshold,
};
} catch (error) {
logger.error('Anthropic balance check failed:', error);
return {
api: 'Anthropic',
currentUsage: 0,
threshold,
exceeded: false,
error: String(error),
};
}
}
export async function GET(request: NextRequest): Promise<NextResponse> {
// 验证 Cron Secret
const authHeader = request.headers.get('authorization');
const cronSecret = process.env.CRON_SECRET;
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
logger.warn('Unauthorized API balance monitor cron job access attempt');
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const results: ApiCheckResult[] = [];
// 并行检查所有 API
const [openaiResult, anthropicResult] = await Promise.all([
checkOpenAI(),
checkAnthropic(),
]);
if (openaiResult) results.push(openaiResult);
if (anthropicResult) results.push(anthropicResult);
// 发送超过阈值的告警
for (const result of results) {
if (result.exceeded) {
const isCritical = result.currentUsage > result.threshold * 1.5;
await notifyApiBalance({
apiName: result.api,
currentBalance: result.currentUsage,
threshold: result.threshold,
isCritical,
});
}
}
return NextResponse.json({
success: true,
checked: results.length,
alerts: results.filter((r) => r.exceeded).length,
results,
});
} catch (error) {
logger.error('API balance monitor cron failed:', error);
return NextResponse.json(
{ success: false, error: 'API balance check failed' },
{ status: 500 }
);
}
}
10.2 配置 Vercel Cron
更新 vercel.json:
{
"functions": {
"app/api/**/*": {
"maxDuration": 60
}
},
"crons": [
{
"path": "/api/cron/api-balance-monitor",
"schedule": "0 */6 * * *"
}
]
}
说明:
0 */6 * * *表示每 6 小时执行一次
11. 测试验证清单
11.1 第一阶段测试
# 1. 启动开发服务器
pnpm dev
# 2. 测试飞书通知(使用 curl 模拟)
# 在另一个终端执行:
curl -X POST http://localhost:3000/api/test-feishu \
-H "Content-Type: application/json" \
-d '{
"eventType": "checkout.completed",
"source": "Test",
"customer": "[email protected]",
"amount": 99,
"currency": "USD",
"orderId": "test_123"
}'
创建测试路由 src/app/api/test-feishu/route.ts:
/**
* 飞书通知测试路由(仅开发环境使用)
*/
import { sendBusinessNotification } from '@/notification/notification';
import { type NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest): Promise<NextResponse> {
// 仅开发环境可用
if (process.env.NODE_ENV === 'production') {
return NextResponse.json({ error: 'Not available in production' }, { status: 403 });
}
try {
const payload = await req.json();
await sendBusinessNotification(payload);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
11.2 完整测试清单
| 测试项 | 验证方法 | 预期结果 |
|---|---|---|
| 飞书卡片消息 | 发起测试支付 | 收到绿色卡片消息 |
| 飞书签名校验 | 检查签名字段 | 消息包含 timestamp 和 sign |
| 多维表格写入 | 查看多维表格 | 新增一条记录 |
| Discord通知 | 发起测试支付 | Discord 收到 Embed 消息 |
| 退款通知 | Stripe 模拟退款 | 收到橙色卡片消息 |
| 争议通知 | Stripe 模拟争议 | 收到红色卡片消息,@所有人 |
| 邮件异常 | 发送到无效邮箱 | 收到邮件退信通知 |
| 监控告警 | Uptime Kuma 测试 | 收到宕机告警 |
| API余额 | 手动触发Cron | 收到余额告警(如超阈值) |
11.3 生产环境验证
# 1. 检查环境变量
vercel env ls | grep FEISHU
# 2. 检查日志
vercel logs --prod | grep "Feishu"
# 3. 发起真实小额测试支付
# 使用 Stripe 测试模式 + 测试卡号
# 4. 验证多维表格记录
# 手动查看飞书多维表格是否有新记录
12. 故障排查指南
12.1 常见错误及解决方案
| 错误 | 原因 | 解决方案 |
|---|---|---|
code: 19021 |
签名验证失败 | 检查 FEISHU_BOT_SECRET 是否正确 |
code: 99991663 |
无权限访问多维表格 | 将应用添加为表格协作者 |
code: 99991668 |
表格不存在 | 检查 APP_TOKEN 和 TABLE_ID |
code: 99991400 |
字段类型不匹配 | 检查多维表格字段类型配置 |
Invalid signature |
Stripe 签名验证失败 | 检查 STRIPE_WEBHOOK_SECRET |
token expired |
飞书 token 过期 | 缓存机制应自动处理,检查代码 |
12.2 调试日志
// 在 src/notification/feishu.ts 开启详细日志:
// 发送前
logger.log('Feishu notification payload:', JSON.stringify(signedMessage, null, 2));
// 响应后
logger.log('Feishu API response:', {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
body: result,
});
12.3 手动测试脚本
# 测试飞书机器人直接发送
curl -X POST "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_HOOK_ID" \
-H "Content-Type: application/json" \
-d '{
"msg_type": "text",
"content": {
"text": "测试消息"
}
}'
# 测试飞书 token 获取
curl -X POST "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" \
-H "Content-Type: application/json" \
-d '{
"app_id": "YOUR_APP_ID",
"app_secret": "YOUR_APP_SECRET"
}'
附录A:文件修改检查清单
新增文件
- [ ]
src/notification/feishu-types.ts - [ ]
src/notification/feishu-sign.ts - [ ]
src/notification/feishu-bitable.ts - [ ]
src/app/api/webhooks/resend/route.ts - [ ]
src/app/api/webhooks/uptime/route.ts - [ ]
src/app/api/cron/api-balance-monitor/route.ts - [ ]
src/app/api/test-feishu/route.ts(仅开发环境)
修改文件
- [ ]
src/notification/feishu.ts(完全重写) - [ ]
src/notification/notification.ts(完全重写) - [ ]
src/payment/provider/stripe.ts(添加事件处理) - [ ]
.env.example(添加新环境变量) - [ ]
vercel.json(添加 Cron 配置)
外部配置
- [ ] 飞书群机器人签名设置
- [ ] 飞书开放平台应用创建
- [ ] 飞书多维表格创建和字段配置
- [ ] Stripe Webhook 事件添加
- [ ] Resend Webhook 配置
- [ ] Uptime Kuma 通知配置
附录B:环境变量完整模板
# =============================================================================
# 飞书业务通知聚合系统 - 环境变量配置
# =============================================================================
# -----------------------------------------------------------------------------
# 飞书群机器人
# -----------------------------------------------------------------------------
# Webhook 地址(必填)
FEISHU_WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# 签名密钥(推荐配置,防止URL泄露被滥用)
FEISHU_BOT_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# -----------------------------------------------------------------------------
# 飞书多维表格(可选,用于存档)
# -----------------------------------------------------------------------------
# 应用凭证
FEISHU_APP_ID="cli_xxxxxxxxxxxxxxxxxx"
FEISHU_APP_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# 表格标识
FEISHU_BITABLE_APP_TOKEN="BASExxxxxxxxxxxxxx"
FEISHU_BITABLE_TABLE_ID="tblxxxxxxxxxxxxxx"
# -----------------------------------------------------------------------------
# Resend Webhook(可选,邮件异常通知)
# -----------------------------------------------------------------------------
RESEND_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# -----------------------------------------------------------------------------
# Uptime Webhook(可选,监控告警)
# -----------------------------------------------------------------------------
UPTIME_WEBHOOK_SECRET="your-secret-key-here"
# -----------------------------------------------------------------------------
# API 余额监控(可选)
# -----------------------------------------------------------------------------
OPENAI_ADMIN_KEY="sk-admin-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
OPENAI_BALANCE_THRESHOLD="100"
ANTHROPIC_ADMIN_KEY="sk-ant-admin-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
ANTHROPIC_BALANCE_THRESHOLD="100"
文档结束
如有问题,请检查日志输出并参考故障排查指南。
本文档为站内渲染。原始文件本地路径:saas/source/knowledge-world/Knowledge-World-项目-文档-feishu-cc-FEISHU_NOTIFICATION_INTEGRAT-185f6b.md(仅本地保留,不入库不部署)