知识库首页 知识库-世界 cc-FEISHU_NOTIFICATION_INTEGRATION.md

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)


目录

  1. 项目现状分析
  2. 目标功能清单
  3. 环境变量配置
  4. 文件结构规划
  5. 第一阶段:飞书通知服务升级
  6. 第二阶段:多维表格存档
  7. 第三阶段:扩展事件类型
  8. 第四阶段:邮件异常通知
  9. 第五阶段:网站监控告警
  10. 第六阶段:API余额监控
  11. 测试验证清单
  12. 故障排查指南

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 现有的安全防护机制

  1. Webhook 签名验证: stripe.ts 使用 constructEvent 验证签名
  2. 幂等性保护: webhook_event 表存储已处理事件,防止重复
  3. 超时保护: Webhook 路由设置 10 秒超时
  4. 错误隔离: 通知失败不影响支付主流程

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:创建飞书开放平台应用

  1. 访问 飞书开放平台
  2. 点击「创建企业自建应用」
  3. 填写应用名称(如:业务通知服务)
  4. 获取 App IDApp Secret
  5. 在「权限管理」中添加: - bitable:app - 多维表格应用权限 - bitable:app:readonly - 多维表格只读权限(可选)

步骤2:创建多维表格

  1. 在飞书中创建新的多维表格
  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:添加应用为协作者

  1. 打开多维表格
  2. 点击右上角「分享」
  3. 添加你创建的应用为协作者(可编辑权限)

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 中添加以下事件监听:

  1. 登录 Stripe Dashboard
  2. 进入 Developers → Webhooks
  3. 编辑现有 Webhook 端点
  4. 添加事件: - 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

  1. 登录 Resend Dashboard
  2. 点击「Add Webhook」
  3. 配置: - Endpoint URL: https://你的域名/api/webhooks/resend - Events: 选择 email.bounced, email.complained, email.delivery_delayed
  4. 复制「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方式:

  1. 在 Uptime Kuma 添加监控项
  2. 设置通知: - 通知类型: 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(仅本地保留,不入库不部署)