package chat import ( "errors" "fmt" "time" "gorm.io/gorm" "gorm.io/gorm/clause" "hfb_sys/backend/internal/model" ) // 会话类型常量 const ( ConversationTypeOrderGroup = "order_group" ConversationTypeListingGroup = "listing_group" ConversationTypeGeneralSupport = "general_support" ) var ( ErrListingConversationExists = errors.New("发布群已存在") ) // EnsureListingConversation 确保发布群存在(幂等) func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferredSupportAdminID uint64) (*model.ChatConversation, error) { // 1. 幂等查重 var existing model.ChatConversation err := tx.Where("listing_id = ?", listing.ID).First(&existing).Error if err == nil { return &existing, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } // 2. 建会话 now := time.Now() conversation := model.ChatConversation{ ListingID: &listing.ID, Type: ConversationTypeListingGroup, Title: listingConversationTitle(listing), Status: "active", } if err := tx.Create(&conversation).Error; err != nil { return nil, err } // 3. 写参与者: 号主 + 客服 participants := []model.ChatParticipant{ { ConversationID: conversation.ID, ParticipantType: "user", ParticipantID: listing.OwnerID, Role: "owner", JoinedAt: now, }, } // 获取客服。开放上传场景优先拉上传管理员,普通发布回退到默认客服。 supportID := preferredSupportAdminID if supportID <= 0 { supportID = defaultSupportAdminID(tx) } if supportID > 0 { participants = append(participants, model.ChatParticipant{ ConversationID: conversation.ID, ParticipantType: "admin", ParticipantID: supportID, Role: "support", JoinedAt: now, }) } for _, participant := range participants { if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil { return nil, err } } // 4. 取二维码(带行锁) var qrcode *model.ChatQrCode var qrcodeImageURL string qrcodeObj, err := fetchUnusedQrCode(tx) if err != nil { return nil, err } if qrcodeObj != nil { qrcode = qrcodeObj qrcodeImageURL = qrcode.ImageURL // 标记二维码为已使用 if err := markQrCodeAsUsed(tx, qrcode.ID, conversation.ID); err != nil { return nil, err } } // 5. 发欢迎语 welcomeMessage := getListingGroupWelcomeMessage(tx) if err := sendSystemMessage(tx, conversation.ID, welcomeMessage); err != nil { return nil, err } // 6. 发二维码图片(如果有) if qrcodeImageURL != "" { if err := sendQrCodeImage(tx, conversation.ID, qrcodeImageURL); err != nil { return nil, err } } else { // 无二维码,发提示 noQrTip := "客服企业微信群二维码补充中,请稍后在群内关注" if err := sendSystemMessage(tx, conversation.ID, noQrTip); err != nil { return nil, err } } // 7. 库存预警检查 if qrcode != nil { if err := checkQrCodeStockAndAlert(tx, &conversation); err != nil { // 预警失败不阻塞建群,仅记录日志 // TODO: 添加日志 } } return &conversation, nil } // AddRenterToListingConversation 拉租客进发布群 func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint64, orderNo string) error { // 1. 查找发布群 var conv model.ChatConversation err := tx.Where("listing_id = ?", listingID).First(&conv).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { // 历史数据无发布群,优雅跳过 return nil } return err } // 2. 幂等插入租客 now := time.Now() participant := model.ChatParticipant{ ConversationID: conv.ID, ParticipantType: "user", ParticipantID: renterID, Role: "renter", JoinedAt: now, // 关键: 记录加入时间,用于消息可见性过滤 } if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil { return err } // 3. 发系统消息 message := "租客已加入群聊(订单 " + orderNo + ")" return sendSystemMessage(tx, conv.ID, message) } // RemoveRenterFromListingConversation 移出租客 func RemoveRenterFromListingConversation(tx *gorm.DB, listingID uint64, renterID uint64) error { // 1. 查找发布群 var conv model.ChatConversation err := tx.Where("listing_id = ?", listingID).First(&conv).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { // 无发布群,跳过 return nil } return err } // 2. 删除租客参与者记录 result := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ? AND role = ?", conv.ID, "user", renterID, "renter"). Delete(&model.ChatParticipant{}) if result.Error != nil { return result.Error } // 3. 发系统消息(如果确实删除了) if result.RowsAffected > 0 { message := "订单已结束,租客已退出群聊" return sendSystemMessage(tx, conv.ID, message) } return nil } // 辅助函数 func listingConversationTitle(listing model.RentalListing) string { return "账号群 " + listing.ListingNo } func getListingGroupWelcomeMessage(tx *gorm.DB) string { var cfg model.SystemConfig if err := tx.Where("`key` = ?", listingGroupWelcomeConfigKey).First(&cfg).Error; err == nil && cfg.Value != "" { return cfg.Value } return defaultListingGroupWelcome } func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error { message := model.ChatMessage{ ConversationID: conversationID, SenderType: "system", SenderRole: "system", ContentType: "system", Content: content, AttachmentURLS: emptyJSONList(), } if err := tx.Create(&message).Error; err != nil { return err } // 更新会话的最后消息 return updateConversationLastMessage(tx, conversationID, &message) } func sendQrCodeImage(tx *gorm.DB, conversationID uint64, imageURL string) error { content := "👇 请扫码加入企业微信群" message := model.ChatMessage{ ConversationID: conversationID, SenderType: "system", SenderRole: "system", ContentType: "image", Content: content, AttachmentURLS: encodeStringList([]string{imageURL}), } if err := tx.Create(&message).Error; err != nil { return err } // 更新会话的最后消息 return updateConversationLastMessage(tx, conversationID, &message) } func updateConversationLastMessage(tx *gorm.DB, conversationID uint64, message *model.ChatMessage) error { updates := map[string]interface{}{ "last_message_id": message.ID, "last_message_preview": truncatePreview(message.Content), "last_message_at": message.CreatedAt, } return tx.Model(&model.ChatConversation{}). Where("id = ?", conversationID). Updates(updates).Error } func 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 } func 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 checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation) error { // 获取库存阈值 threshold := int64(5) var cfg model.SystemConfig if err := tx.Where("`key` = ?", "chat.qrcode_low_stock_threshold").First(&cfg).Error; err == nil && cfg.Value != "" { // 尝试解析为数字 if val, err := parseThreshold(cfg.Value); err == nil { threshold = val } } // 统计未使用的二维码数量 var count int64 if err := tx.Model(&model.ChatQrCode{}). Where("status = ?", QrCodeStatusUnused). Where("expires_at IS NULL OR expires_at > ?", time.Now()). Count(&count).Error; err != nil { return err } // 如果低于阈值,发送预警给所有客服 if count <= threshold { // 查询所有 cs 角色的客服 var csAdmins []model.AdminUser if err := tx.Table("admin_users"). Joins("JOIN admin_user_roles ON admin_users.id = admin_user_roles.admin_user_id"). Joins("JOIN roles ON admin_user_roles.role_id = roles.id"). Where("roles.code = ? AND admin_users.status = ?", "cs", "active"). Select("admin_users.id"). Find(&csAdmins).Error; err != nil { return err } // 构造预警消息 alertContent := fmt.Sprintf("企业微信群二维码库存不足(剩余 %d 张),请及时补充", count) // 发送站内信给所有客服 entries := make([]map[string]interface{}, 0, len(csAdmins)) now := time.Now() for _, admin := range csAdmins { entries = append(entries, map[string]interface{}{ "admin_user_id": admin.ID, "type": "system", "title": "二维码库存预警", "content": alertContent, "is_read": false, "created_at": now, "updated_at": now, }) } if len(entries) > 0 { // 批量插入管理员通知 if err := tx.Table("admin_notifications").Create(entries).Error; err != nil { return err } } } return nil } func parseThreshold(value string) (int64, error) { var threshold int64 if _, err := fmt.Sscanf(value, "%d", &threshold); err != nil { return 0, err } return threshold, nil }