package chat import ( "context" "errors" "strconv" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/chathub" ) // QrCodeStatus 二维码状态常量 const ( QrCodeStatusUnused = "unused" QrCodeStatusUsed = "used" QrCodeStatusDisabled = "disabled" qrCodeOCRTokenConfigKey = "integration.paddle_ocr_token" qrCodeOCRJobURLConfigKey = "integration.paddle_ocr_job_url" qrCodeOCRModelConfigKey = "integration.paddle_ocr_model" defaultQrCodeOCRJobURL = "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs" defaultQrCodeOCRModel = "PaddleOCR-VL-1.6" ) var ( ErrQrCodeNotFound = errors.New("二维码不存在") ErrQrCodeCannotReenable = errors.New("已发放的二维码不能改回待用") ) // CreateQrCodeRequest 创建二维码请求 type CreateQrCodeRequest struct { ImageURL string `json:"image_url" binding:"required"` GroupName string `json:"group_name"` Note string `json:"note"` ExpiresAt *time.Time `json:"expires_at"` } // BatchCreateQrCodeRequest 批量创建二维码请求 type BatchCreateQrCodeRequest struct { Items []CreateQrCodeRequest `json:"items" binding:"required,min=1,max=20"` } // BatchDeleteQrCodeRequest 批量删除二维码请求 type BatchDeleteQrCodeRequest struct { IDs []uint64 `json:"ids" binding:"required,min=1,max=100"` } // UpdateQrCodeRequest 更新二维码请求 type UpdateQrCodeRequest struct { ImageURL *string `json:"image_url"` GroupName *string `json:"group_name"` Note *string `json:"note"` Status *string `json:"status"` WecomRenamed *bool `json:"wecom_renamed"` ExpiresAt *time.Time `json:"expires_at"` ClearExpiresAt bool `json:"clear_expires_at"` } // QrCodeListRequest 列表查询请求 type QrCodeListRequest struct { Status string `form:"status"` Keyword string `form:"keyword"` WecomRenamed string `form:"wecom_renamed"` Bound string `form:"bound"` ExpireStatus string `form:"expire_status"` CreatedStart string `form:"created_start"` CreatedEnd string `form:"created_end"` Page int `form:"page"` Limit int `form:"limit"` } // QrCodeStats 二维码统计 type QrCodeStats struct { UnusedCount int64 `json:"unused_count"` UsedCount int64 `json:"used_count"` DisabledCount int64 `json:"disabled_count"` TotalCount int64 `json:"total_count"` } // QrCodeListItem 二维码列表项 type QrCodeListItem struct { model.ChatQrCode BoundConversationTitle string `json:"bound_conversation_title"` } // QrCodeOCRConfig 二维码 OCR 调用配置 type QrCodeOCRConfig struct { Token string `json:"token"` JobURL string `json:"job_url"` Model string `json:"model"` } // CreateQrCode 创建二维码 func (r *Repository) CreateQrCode(ctx context.Context, adminID uint64, req CreateQrCodeRequest) (*model.ChatQrCode, error) { var qrcode model.ChatQrCode var deliveredMessages []model.ChatMessage err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { qrcode = model.ChatQrCode{ ImageURL: req.ImageURL, GroupName: req.GroupName, Status: QrCodeStatusUnused, CreatedBy: adminID, Note: req.Note, ExpiresAt: req.ExpiresAt, } // 如果未指定过期时间,默认7天后过期 if qrcode.ExpiresAt == nil { expires := time.Now().Add(7 * 24 * time.Hour) qrcode.ExpiresAt = &expires } if err := tx.Create(&qrcode).Error; err != nil { return err } var err error deliveredMessages, err = deliverPendingQrCodes(tx) return err }) if err != nil { return nil, err } r.notifyDeliveredQrCodeMessages(deliveredMessages) return &qrcode, nil } // BatchCreateQrCode 批量创建二维码 func (r *Repository) BatchCreateQrCode(ctx context.Context, adminID uint64, req BatchCreateQrCodeRequest) ([]model.ChatQrCode, error) { var qrcodes []model.ChatQrCode var deliveredMessages []model.ChatMessage defaultExpires := time.Now().Add(7 * 24 * time.Hour) err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for _, item := range req.Items { qr := model.ChatQrCode{ ImageURL: item.ImageURL, GroupName: item.GroupName, Status: QrCodeStatusUnused, CreatedBy: adminID, Note: item.Note, ExpiresAt: item.ExpiresAt, } if qr.ExpiresAt == nil { qr.ExpiresAt = &defaultExpires } qrcodes = append(qrcodes, qr) } if err := tx.Create(&qrcodes).Error; err != nil { return err } var err error deliveredMessages, err = deliverPendingQrCodes(tx) return err }) if err != nil { return nil, err } r.notifyDeliveredQrCodeMessages(deliveredMessages) return qrcodes, nil } // ListQrCodes 列表查询二维码 func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]QrCodeListItem, int64, error) { if req.Page < 1 { req.Page = 1 } if req.Limit < 1 || req.Limit > 100 { req.Limit = 20 } now := time.Now() baseQuery := func() *gorm.DB { return applyQrCodeListFilters( r.db.WithContext(ctx). Table("chat_qrcode_pool AS q"). Joins("LEFT JOIN chat_conversations AS c ON c.id = q.conversation_id"), req, now, ) } // 统计总数 var total int64 if err := baseQuery().Count(&total).Error; err != nil { return nil, 0, err } // 查询列表 var qrcodes []QrCodeListItem offset := (req.Page - 1) * req.Limit if err := baseQuery(). Select("q.*, COALESCE(c.title, '') AS bound_conversation_title"). Order("q.id DESC"). Offset(offset). Limit(req.Limit). Scan(&qrcodes).Error; err != nil { return nil, 0, err } return qrcodes, total, nil } func applyQrCodeListFilters(query *gorm.DB, req QrCodeListRequest, now time.Time) *gorm.DB { if req.Status != "" { query = query.Where("q.status = ?", req.Status) } if keyword := strings.TrimSpace(req.Keyword); keyword != "" { like := "%" + keyword + "%" conditions := "(q.group_name LIKE ? OR q.note LIKE ? OR COALESCE(c.title, '') LIKE ?" args := []interface{}{like, like, like} if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { conditions += " OR q.id = ? OR q.conversation_id = ?" args = append(args, id, id) } conditions += ")" query = query.Where(conditions, args...) } if value, ok := parseQrCodeBoolFilter(req.WecomRenamed); ok { query = query.Where("q.wecom_renamed = ?", value) } if value, ok := parseQrCodeBoolFilter(req.Bound); ok { if value { query = query.Where("q.conversation_id IS NOT NULL") } else { query = query.Where("q.conversation_id IS NULL") } } switch req.ExpireStatus { case "valid": query = query.Where("(q.expires_at IS NULL OR q.expires_at > ?)", now) case "expired": query = query.Where("q.expires_at IS NOT NULL AND q.expires_at <= ?", now) case "permanent": query = query.Where("q.expires_at IS NULL") case "expiring_soon": query = query.Where("q.expires_at > ? AND q.expires_at <= ?", now, now.Add(72*time.Hour)) } if start, ok := parseQrCodeFilterTime(req.CreatedStart); ok { query = query.Where("q.created_at >= ?", start) } if end, ok := parseQrCodeFilterTime(req.CreatedEnd); ok { query = query.Where("q.created_at <= ?", end) } return query } func parseQrCodeBoolFilter(value string) (bool, bool) { switch strings.ToLower(strings.TrimSpace(value)) { case "true", "1", "yes": return true, true case "false", "0", "no": return false, true default: return false, false } } func parseQrCodeFilterTime(value string) (time.Time, bool) { value = strings.TrimSpace(value) if value == "" { return time.Time{}, false } layouts := []string{ time.RFC3339, "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02", } for _, layout := range layouts { if parsed, err := time.Parse(layout, value); err == nil { return parsed, true } } return time.Time{}, false } // GetQrCodeStats 获取二维码统计信息 func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) { stats := &QrCodeStats{} now := time.Now() if err := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).Count(&stats.TotalCount).Error; err != nil { return nil, err } if err := r.db.WithContext(ctx). Model(&model.ChatQrCode{}). Where("status = ?", QrCodeStatusUsed). Count(&stats.UsedCount).Error; err != nil { return nil, err } if err := r.db.WithContext(ctx). Model(&model.ChatQrCode{}). Where("status = ?", QrCodeStatusDisabled). Count(&stats.DisabledCount).Error; err != nil { return nil, err } if err := r.db.WithContext(ctx). Model(&model.ChatQrCode{}). Where("status = ?", QrCodeStatusUnused). Where("expires_at IS NULL OR expires_at > ?", now). Count(&stats.UnusedCount).Error; err != nil { return nil, err } return stats, nil } // GetQrCodeOCRConfig 获取前端直连 PaddleOCR 所需配置 func (r *Repository) GetQrCodeOCRConfig(ctx context.Context) (*QrCodeOCRConfig, error) { config := &QrCodeOCRConfig{ JobURL: defaultQrCodeOCRJobURL, Model: defaultQrCodeOCRModel, } var rows []model.SystemConfig keys := []string{qrCodeOCRTokenConfigKey, qrCodeOCRJobURLConfigKey, qrCodeOCRModelConfigKey} if err := r.db.WithContext(ctx).Where("`key` IN ?", keys).Find(&rows).Error; err != nil { return nil, err } for _, row := range rows { switch row.Key { case qrCodeOCRTokenConfigKey: config.Token = row.Value case qrCodeOCRJobURLConfigKey: if row.Value != "" { config.JobURL = row.Value } case qrCodeOCRModelConfigKey: if row.Value != "" { config.Model = row.Value } } } return config, nil } // UpdateQrCode 更新二维码 func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCodeRequest) error { var qrcode model.ChatQrCode if err := r.db.WithContext(ctx).First(&qrcode, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrQrCodeNotFound } return err } updates := make(map[string]interface{}) if req.ImageURL != nil { updates["image_url"] = *req.ImageURL } if req.GroupName != nil { updates["group_name"] = *req.GroupName } if req.Note != nil { updates["note"] = *req.Note } if req.WecomRenamed != nil { updates["wecom_renamed"] = *req.WecomRenamed } if req.Status != nil { // 校验状态值 if *req.Status != QrCodeStatusUnused && *req.Status != QrCodeStatusUsed && *req.Status != QrCodeStatusDisabled { return errors.New("无效的状态值") } if *req.Status == QrCodeStatusUnused && qrcodeWasIssued(qrcode) { return ErrQrCodeCannotReenable } updates["status"] = *req.Status } if req.ExpiresAt != nil { updates["expires_at"] = req.ExpiresAt } else if req.ClearExpiresAt { updates["expires_at"] = nil } if len(updates) == 0 { return nil } if err := r.db.WithContext(ctx).Model(&qrcode).Updates(updates).Error; err != nil { return err } return nil } func qrcodeWasIssued(qrcode model.ChatQrCode) bool { return qrcode.Status == QrCodeStatusUsed || qrcode.ConversationID != nil || qrcode.UsedAt != nil } // DeleteQrCode 删除二维码 func (r *Repository) DeleteQrCode(ctx context.Context, id uint64) error { var qrcode model.ChatQrCode if err := r.db.WithContext(ctx).First(&qrcode, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrQrCodeNotFound } return err } return r.db.WithContext(ctx).Delete(&qrcode).Error } // BatchDeleteQrCodes 批量删除二维码 func (r *Repository) BatchDeleteQrCodes(ctx context.Context, ids []uint64) (int64, error) { if len(ids) == 0 { return 0, nil } result := r.db.WithContext(ctx).Where("id IN ?", ids).Delete(&model.ChatQrCode{}) if result.Error != nil { return 0, result.Error } return result.RowsAffected, nil } // fetchUnusedQrCode 获取一个未使用且未过期的二维码(带行锁) func (r *Repository) fetchUnusedQrCode(tx *gorm.DB) (*model.ChatQrCode, error) { var qrcode model.ChatQrCode now := time.Now() err := tx.Where("status = ?", QrCodeStatusUnused). Where("expires_at IS NULL OR expires_at > ?", now). Order("id ASC"). Limit(1). Clauses(clause.Locking{Strength: "UPDATE"}). First(&qrcode).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil // 无可用二维码,返回 nil 而非错误 } return nil, err } return &qrcode, nil } // markQrCodeAsUsed 标记二维码为已使用 func (r *Repository) markQrCodeAsUsed(tx *gorm.DB, qrcodeID uint64, conversationID uint64) error { now := time.Now() return tx.Model(&model.ChatQrCode{}). Where("id = ?", qrcodeID). Updates(map[string]interface{}{ "status": QrCodeStatusUsed, "conversation_id": conversationID, "used_at": now, }).Error } func deliverPendingQrCodes(tx *gorm.DB) ([]model.ChatMessage, error) { deliveredMessages := make([]model.ChatMessage, 0) for { var task model.ChatQrCodeDeliveryTask if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("status = ?", "pending"). Order("created_at ASC, id ASC"). First(&task).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return deliveredMessages, nil } return nil, err } qrcode, err := fetchUnusedQrCode(tx) if err != nil { return nil, err } if qrcode == nil { return deliveredMessages, nil } if err := markQrCodeAsUsed(tx, qrcode.ID, task.ConversationID); err != nil { return nil, err } message, err := sendQrCodeImage(tx, task.ConversationID, qrcode.ImageURL) if err != nil { now := time.Now() if updateErr := tx.Model(&task).Updates(map[string]interface{}{ "status": "failed", "qrcode_id": qrcode.ID, "error_message": err.Error(), "updated_at": now, }).Error; updateErr != nil { return nil, updateErr } continue } deliveredMessages = append(deliveredMessages, *message) now := time.Now() if err := tx.Model(&task).Updates(map[string]interface{}{ "status": "sent", "qrcode_id": qrcode.ID, "error_message": "", "sent_at": now, "updated_at": now, }).Error; err != nil { return nil, err } } } func (r *Repository) notifyDeliveredQrCodeMessages(messages []model.ChatMessage) { if r.hub == nil { return } for _, msg := range messages { event := &chathub.ChatEvent{ Type: "new_message", ConversationID: msg.ConversationID, Message: &chathub.MessageData{ ID: msg.ID, ConversationID: msg.ConversationID, SenderType: msg.SenderType, SenderID: msg.SenderID, SenderRole: msg.SenderRole, SenderName: "系统", ContentType: msg.ContentType, Content: msg.Content, AttachmentURLS: decodeStringList(msg.AttachmentURLS), CreatedAt: msg.CreatedAt.Format(time.RFC3339), }, } r.hub.NotifyConversation(msg.ConversationID, event) r.hub.NotifyAllAdmins(event) } }