feat(config): 添加飞书Webhook配置支持,并在定时任务中注册价格审核检查功能

This commit is contained in:
wood chen 2025-07-17 22:11:31 +08:00
parent 07ebd78cdd
commit 81a5eb61e9
6 changed files with 686 additions and 0 deletions

160
FEISHU_WEBHOOK_README.md Normal file
View File

@ -0,0 +1,160 @@
# 飞书Webhook通知功能
本系统支持通过飞书Webhook发送待审核价格的通知帮助管理员及时了解需要处理的价格审核请求。
## 功能特性
- 🕐 **定期检查**每5分钟自动检查一次待审核价格
- 📊 **智能汇总**:按厂商分组统计待审核价格数量
- 🔔 **智能通知**30分钟内不重复发送相同通知避免打扰
- 📋 **详细信息**显示最近的5个待审核价格详情
- 🎨 **美观卡片**:使用飞书卡片格式,信息展示清晰美观
## 配置方法
### 1. 创建飞书自定义机器人
1. 在飞书群聊中,点击右上角设置按钮
2. 选择"群机器人" -> "添加机器人" -> "自定义机器人"
3. 填写机器人名称AI模型价格通知
4. 复制生成的Webhook地址
### 2. 配置环境变量
#### 方法一Docker Compose配置
编辑 `docker-compose.yml` 文件:
```yaml
services:
aimodels-prices:
environment:
- FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your-webhook-url
```
#### 方法二:环境变量文件
`data/.env` 文件中添加:
```bash
FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your-webhook-url
```
#### 方法三:系统环境变量
```bash
export FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your-webhook-url
```
### 3. 重启服务
配置完成后重启应用:
```bash
docker-compose down
docker-compose up -d
```
## 通知内容
### 通知时机
- 系统每5分钟检查一次待审核价格
- 只有当存在待审核价格时才发送通知
- 30分钟内不会重复发送相同的通知
### 通知内容
通知卡片包含以下信息:
1. **总计统计**:待审核价格总数
2. **分厂商统计**:按厂商分组的价格数量
3. **价格详情**最近的5个待审核价格信息包括
- 模型名称
- 所属厂商
- 创建者
4. **操作提醒**:提示管理员及时处理
### 示例通知
```
🔍 待审核价格检查报告 - 8个待审核
📋 待审核价格统计
总计: 8 个模型价格待审核
分厂商统计:
- OpenAI3 个模型
- Anthropic2 个模型
- 字节跳动3 个模型
最近待审核价格最多显示5个
1. gpt-4o-mini (OpenAI) - 创建者:张三
2. claude-3-sonnet (Anthropic) - 创建者:李四
3. doubao-pro-4k (字节跳动) - 创建者:王五
4. gpt-4o (OpenAI) - 创建者:赵六
5. claude-3-haiku (Anthropic) - 创建者:钱七
...还有 3 个价格等待审核
⏰ 请及时处理待审核价格!
```
## 功能说明
### 自动化检查
- 定时任务每5分钟运行一次
- 自动查询数据库中状态为 `pending` 的价格记录
- 如果没有待审核价格,不会发送通知
### 防止打扰
- 系统记录上次发送通知的时间
- 30分钟内不会重复发送相同内容的通知
- 避免频繁通知造成打扰
### 异步处理
- 通知发送采用异步方式
- 不会阻塞主要业务流程
- 即使通知发送失败,也不影响系统正常运行
## 故障排除
### 通知未收到
1. **检查配置**:确认 `FEISHU_WEBHOOK_URL` 环境变量已正确设置
2. **检查网络**确认服务器能够访问飞书API
3. **检查日志**:查看应用日志中是否有错误信息
4. **检查机器人**:确认飞书群中的机器人未被移除
### 查看日志
```bash
# 查看容器日志
docker-compose logs -f aimodels-prices
# 查看最近的日志
docker-compose logs --tail=100 aimodels-prices
```
### 常见错误
- `webhook returned status code: 400`Webhook地址错误或格式不正确
- `failed to send webhook: connection refused`:网络连接问题
- `未配置飞书webhook跳过通知`:环境变量未设置
## 安全说明
- Webhook URL包含敏感token请妥善保管
- 建议定期更换Webhook URL
- 不要在公开的代码仓库中暴露Webhook URL
## 技术实现
- 使用Go的cron库实现定时任务
- 采用飞书卡片格式发送美观的通知
- 支持异步发送,不阻塞主流程
- 智能去重,避免重复通知

View File

@ -21,6 +21,9 @@ type Config struct {
// SQLite配置用于数据迁移
SQLitePath string
// 飞书Webhook配置
FeishuWebhookURL string
}
func LoadConfig() (*Config, error) {
@ -53,6 +56,9 @@ func LoadConfig() (*Config, error) {
// SQLite路径用于数据迁移
SQLitePath: filepath.Join(dbDir, "aimodels.db"),
// 飞书Webhook配置
FeishuWebhookURL: getEnv("FEISHU_WEBHOOK_URL", ""),
}
return config, nil

View File

@ -7,6 +7,7 @@ import (
"github.com/robfig/cron/v3"
openrouter_api "aimodels-prices/cron/openrouter-api"
price_audit "aimodels-prices/cron/price-audit"
siliconflow_api "aimodels-prices/cron/siliconflow-api"
)
@ -43,6 +44,18 @@ func Init() {
log.Printf("注册价格更新定时任务失败: %v", err)
}
// 注册价格审核检查任务
// 每5分钟执行一次
_, err = cronScheduler.AddFunc("0 */5 * * * *", func() {
if err := price_audit.CheckPendingPrices(); err != nil {
log.Printf("价格审核检查任务执行失败: %v", err)
}
})
if err != nil {
log.Printf("注册价格审核检查定时任务失败: %v", err)
}
// 启动定时任务
cronScheduler.Start()
log.Println("定时任务已启动")

View File

@ -0,0 +1,129 @@
package price_audit
import (
"fmt"
"log"
"time"
"aimodels-prices/database"
"aimodels-prices/models"
"aimodels-prices/notification"
)
// lastNotificationTime 记录上次发送通知的时间,避免重复发送
var lastNotificationTime time.Time
// CheckPendingPrices 检查待审核价格并发送通知
func CheckPendingPrices() error {
log.Println("开始检查待审核价格...")
// 查询所有待审核的价格
var pendingPrices []models.Price
if err := database.DB.Where("status = 'pending'").Find(&pendingPrices).Error; err != nil {
log.Printf("查询待审核价格失败: %v", err)
return err
}
if len(pendingPrices) == 0 {
log.Println("当前没有待审核的价格")
return nil
}
log.Printf("发现 %d 个待审核价格", len(pendingPrices))
// 检查是否需要发送通知(避免频繁发送)
now := time.Now()
if now.Sub(lastNotificationTime) < 24*time.Hour {
log.Println("距离上次通知时间较短,跳过本次通知")
return nil
}
// 发送飞书通知
webhook := notification.NewFeishuWebhook()
if webhook == nil {
log.Println("未配置飞书webhook跳过通知")
return nil
}
// 异步发送通知
go func() {
if err := sendPendingPricesNotification(webhook, pendingPrices); err != nil {
log.Printf("发送飞书通知失败: %v", err)
} else {
log.Printf("成功发送飞书通知,包含 %d 个待审核价格", len(pendingPrices))
lastNotificationTime = now
}
}()
return nil
}
// sendPendingPricesNotification 发送待审核价格的详细通知
func sendPendingPricesNotification(webhook *notification.FeishuWebhook, pendingPrices []models.Price) error {
// 按厂商分组统计
providerStats := make(map[uint][]models.Price)
for _, price := range pendingPrices {
channelType := getChannelType(price)
providerStats[channelType] = append(providerStats[channelType], price)
}
// 构建详细的通知内容
content := fmt.Sprintf("📋 **待审核价格统计**\n\n**总计:** %d 个模型价格待审核\n\n", len(pendingPrices))
// 按厂商分组显示
content += "**分厂商统计:**\n"
for channelType, prices := range providerStats {
var provider models.Provider
if err := database.DB.Where("id = ?", channelType).First(&provider).Error; err != nil {
provider.Name = fmt.Sprintf("厂商ID:%d", channelType)
}
content += fmt.Sprintf("- %s%d 个模型\n", provider.Name, len(prices))
}
// 显示最近的几个待审核价格
content += "\n**最近待审核价格最多显示5个**\n"
maxDisplay := 5
if len(pendingPrices) < maxDisplay {
maxDisplay = len(pendingPrices)
}
for i := 0; i < maxDisplay; i++ {
price := pendingPrices[i]
var provider models.Provider
channelType := getChannelType(price)
if err := database.DB.Where("id = ?", channelType).First(&provider).Error; err != nil {
provider.Name = fmt.Sprintf("厂商ID:%d", channelType)
}
content += fmt.Sprintf("%d. **%s** (%s) - 创建者:%s\n",
i+1,
getDisplayModel(price),
provider.Name,
price.CreatedBy)
}
if len(pendingPrices) > maxDisplay {
content += fmt.Sprintf("\n...还有 %d 个价格等待审核", len(pendingPrices)-maxDisplay)
}
content += "\n\n⏰ 请及时处理待审核价格!"
// 发送卡片通知
return webhook.SendPendingPricesDetailedNotification(content, len(pendingPrices))
}
// getChannelType 获取厂商类型
func getChannelType(price models.Price) uint {
if price.TempChannelType != nil {
return *price.TempChannelType
}
return price.ChannelType
}
// getDisplayModel 获取显示用的模型名称
func getDisplayModel(price models.Price) string {
if price.TempModel != nil {
return *price.TempModel
}
return price.Model
}

View File

@ -0,0 +1,376 @@
package notification
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"aimodels-prices/models"
)
// FeishuWebhook 飞书webhook配置
type FeishuWebhook struct {
URL string
}
// TextMessage 文本消息结构
type TextMessage struct {
MsgType string `json:"msg_type"`
Content struct {
Text string `json:"text"`
} `json:"content"`
}
// CardMessage 卡片消息结构
type CardMessage struct {
MsgType string `json:"msg_type"`
Card Card `json:"card"`
}
// Card 卡片结构
type Card struct {
Schema string `json:"schema"`
Config CardConfig `json:"config"`
Header CardHeader `json:"header"`
Body CardBody `json:"body"`
}
// CardConfig 卡片配置
type CardConfig struct {
UpdateMulti bool `json:"update_multi"`
}
// CardHeader 卡片头部
type CardHeader struct {
Title Title `json:"title"`
Template string `json:"template"`
Padding string `json:"padding"`
}
// Title 标题
type Title struct {
Tag string `json:"tag"`
Content string `json:"content"`
}
// CardBody 卡片主体
type CardBody struct {
Direction string `json:"direction"`
Padding string `json:"padding"`
Elements []CardElement `json:"elements"`
}
// CardElement 卡片元素
type CardElement struct {
Tag string `json:"tag"`
Content string `json:"content,omitempty"`
TextAlign string `json:"text_align,omitempty"`
TextSize string `json:"text_size,omitempty"`
Margin string `json:"margin,omitempty"`
}
// NewFeishuWebhook 创建飞书webhook实例
func NewFeishuWebhook() *FeishuWebhook {
url := os.Getenv("FEISHU_WEBHOOK_URL")
if url == "" {
return nil
}
return &FeishuWebhook{URL: url}
}
// SendTextMessage 发送文本消息
func (f *FeishuWebhook) SendTextMessage(text string) error {
if f == nil || f.URL == "" {
return nil // 如果没有配置webhook则跳过
}
message := TextMessage{
MsgType: "text",
Content: struct {
Text string `json:"text"`
}{
Text: text,
},
}
return f.sendMessage(message)
}
// SendPendingPriceNotification 发送待审核价格通知卡片
func (f *FeishuWebhook) SendPendingPriceNotification(price models.Price, providerName string, isNew bool) error {
if f == nil || f.URL == "" {
return nil // 如果没有配置webhook则跳过
}
var actionText string
if isNew {
actionText = "新增"
} else {
actionText = "更新"
}
// 构建卡片内容
content := fmt.Sprintf("**%s模型价格**\n\n", actionText)
content += fmt.Sprintf("**模型名称:** %s\n", getDisplayModel(price))
content += fmt.Sprintf("**厂商:** %s\n", providerName)
content += fmt.Sprintf("**计费类型:** %s\n", getBillingTypeText(getDisplayBillingType(price)))
content += fmt.Sprintf("**输入价格:** %.6f %s/1K tokens\n", getDisplayInputPrice(price), getDisplayCurrency(price))
content += fmt.Sprintf("**输出价格:** %.6f %s/1K tokens\n", getDisplayOutputPrice(price), getDisplayCurrency(price))
content += fmt.Sprintf("**创建者:** %s\n", price.CreatedBy)
content += fmt.Sprintf("**创建时间:** %s", time.Now().Format("2006-01-02 15:04:05"))
// 如果有扩展价格字段,也显示出来
if hasExtendedPrices(price) {
content += "\n\n**扩展价格:**\n"
if getDisplayInputAudioTokens(price) != nil {
content += fmt.Sprintf("- 音频输入:%.6f %s/1K tokens\n", *getDisplayInputAudioTokens(price), getDisplayCurrency(price))
}
if getDisplayOutputAudioTokens(price) != nil {
content += fmt.Sprintf("- 音频输出:%.6f %s/1K tokens\n", *getDisplayOutputAudioTokens(price), getDisplayCurrency(price))
}
if getDisplayCachedTokens(price) != nil {
content += fmt.Sprintf("- 缓存:%.6f %s/1K tokens\n", *getDisplayCachedTokens(price), getDisplayCurrency(price))
}
if getDisplayReasoningTokens(price) != nil {
content += fmt.Sprintf("- 推理:%.6f %s/1K tokens\n", *getDisplayReasoningTokens(price), getDisplayCurrency(price))
}
}
card := CardMessage{
MsgType: "interactive",
Card: Card{
Schema: "2.0",
Config: CardConfig{
UpdateMulti: true,
},
Header: CardHeader{
Title: Title{
Tag: "plain_text",
Content: fmt.Sprintf("🔔 有新的价格待审核 - %s", actionText),
},
Template: "orange",
Padding: "12px 12px 12px 12px",
},
Body: CardBody{
Direction: "vertical",
Padding: "12px 12px 12px 12px",
Elements: []CardElement{
{
Tag: "markdown",
Content: content,
TextAlign: "left",
TextSize: "normal",
Margin: "0px 0px 0px 0px",
},
},
},
},
}
return f.sendMessage(card)
}
// SendBatchNotification 发送批量通知
func (f *FeishuWebhook) SendBatchNotification(count int) error {
if f == nil || f.URL == "" {
return nil
}
content := fmt.Sprintf("📢 **批量价格更新通知**\n\n本次共有 **%d** 个模型价格等待审核,请及时处理。", count)
card := CardMessage{
MsgType: "interactive",
Card: Card{
Schema: "2.0",
Config: CardConfig{
UpdateMulti: true,
},
Header: CardHeader{
Title: Title{
Tag: "plain_text",
Content: "📊 批量价格更新通知",
},
Template: "blue",
Padding: "12px 12px 12px 12px",
},
Body: CardBody{
Direction: "vertical",
Padding: "12px 12px 12px 12px",
Elements: []CardElement{
{
Tag: "markdown",
Content: content,
TextAlign: "left",
TextSize: "normal",
Margin: "0px 0px 0px 0px",
},
},
},
},
}
return f.sendMessage(card)
}
// SendPendingPricesDetailedNotification 发送详细的待审核价格统计通知
func (f *FeishuWebhook) SendPendingPricesDetailedNotification(content string, count int) error {
if f == nil || f.URL == "" {
return nil
}
card := CardMessage{
MsgType: "interactive",
Card: Card{
Schema: "2.0",
Config: CardConfig{
UpdateMulti: true,
},
Header: CardHeader{
Title: Title{
Tag: "plain_text",
Content: fmt.Sprintf("🔍 待审核价格检查报告 - %d个待审核", count),
},
Template: "red",
Padding: "12px 12px 12px 12px",
},
Body: CardBody{
Direction: "vertical",
Padding: "12px 12px 12px 12px",
Elements: []CardElement{
{
Tag: "markdown",
Content: content,
TextAlign: "left",
TextSize: "normal",
Margin: "0px 0px 0px 0px",
},
},
},
},
}
return f.sendMessage(card)
}
// sendMessage 发送消息到飞书
func (f *FeishuWebhook) sendMessage(message interface{}) error {
jsonData, err := json.Marshal(message)
if err != nil {
return fmt.Errorf("failed to marshal message: %v", err)
}
resp, err := http.Post(f.URL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to send webhook: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("webhook returned status code: %d", resp.StatusCode)
}
return nil
}
// 辅助函数:获取显示用的模型名称
func getDisplayModel(price models.Price) string {
if price.TempModel != nil {
return *price.TempModel
}
return price.Model
}
// 辅助函数:获取显示用的计费类型
func getDisplayBillingType(price models.Price) string {
if price.TempBillingType != nil {
return *price.TempBillingType
}
return price.BillingType
}
// 辅助函数:获取显示用的货币
func getDisplayCurrency(price models.Price) string {
if price.TempCurrency != nil {
return *price.TempCurrency
}
return price.Currency
}
// 辅助函数:获取显示用的输入价格
func getDisplayInputPrice(price models.Price) float64 {
if price.TempInputPrice != nil {
return *price.TempInputPrice
}
return price.InputPrice
}
// 辅助函数:获取显示用的输出价格
func getDisplayOutputPrice(price models.Price) float64 {
if price.TempOutputPrice != nil {
return *price.TempOutputPrice
}
return price.OutputPrice
}
// 辅助函数:获取显示用的音频输入价格
func getDisplayInputAudioTokens(price models.Price) *float64 {
if price.TempInputAudioTokens != nil {
return price.TempInputAudioTokens
}
return price.InputAudioTokens
}
// 辅助函数:获取显示用的音频输出价格
func getDisplayOutputAudioTokens(price models.Price) *float64 {
if price.TempOutputAudioTokens != nil {
return price.TempOutputAudioTokens
}
return price.OutputAudioTokens
}
// 辅助函数:获取显示用的缓存价格
func getDisplayCachedTokens(price models.Price) *float64 {
if price.TempCachedTokens != nil {
return price.TempCachedTokens
}
return price.CachedTokens
}
// 辅助函数:获取显示用的推理价格
func getDisplayReasoningTokens(price models.Price) *float64 {
if price.TempReasoningTokens != nil {
return price.TempReasoningTokens
}
return price.ReasoningTokens
}
// 辅助函数:检查是否有扩展价格字段
func hasExtendedPrices(price models.Price) bool {
return getDisplayInputAudioTokens(price) != nil ||
getDisplayOutputAudioTokens(price) != nil ||
getDisplayCachedTokens(price) != nil ||
getDisplayReasoningTokens(price) != nil
}
// 辅助函数:获取计费类型中文显示
func getBillingTypeText(billingType string) string {
switch billingType {
case "token":
return "按Token计费"
case "request":
return "按请求计费"
case "minute":
return "按分钟计费"
case "hour":
return "按小时计费"
case "day":
return "按天计费"
case "month":
return "按月计费"
default:
return billingType
}
}

View File

@ -7,6 +7,8 @@ services:
- GIN_MODE=release
- PORT=8080
- TZ=Asia/Shanghai
# 飞书Webhook配置可选
# - FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your-webhook-url
volumes:
- ./data:/app/data
ports: