diff --git a/FEISHU_WEBHOOK_README.md b/FEISHU_WEBHOOK_README.md new file mode 100644 index 0000000..543422b --- /dev/null +++ b/FEISHU_WEBHOOK_README.md @@ -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 个模型价格待审核 + +分厂商统计: +- OpenAI:3 个模型 +- Anthropic:2 个模型 +- 字节跳动: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库实现定时任务 +- 采用飞书卡片格式发送美观的通知 +- 支持异步发送,不阻塞主流程 +- 智能去重,避免重复通知 \ No newline at end of file diff --git a/backend/config/config.go b/backend/config/config.go index f2e6d6f..a624235 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -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 diff --git a/backend/cron/main.go b/backend/cron/main.go index 5220474..c1b1779 100644 --- a/backend/cron/main.go +++ b/backend/cron/main.go @@ -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("定时任务已启动") diff --git a/backend/cron/price-audit/check.go b/backend/cron/price-audit/check.go new file mode 100644 index 0000000..6c41a79 --- /dev/null +++ b/backend/cron/price-audit/check.go @@ -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 +} diff --git a/backend/notification/feishu.go b/backend/notification/feishu.go new file mode 100644 index 0000000..b00216a --- /dev/null +++ b/backend/notification/feishu.go @@ -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 + } +} diff --git a/docker-compose.yml b/docker-compose.yml index abe04ae..1707bf9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: