package chat import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" "hfb_sys/backend/internal/integrations/push" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/supportgroup" ) // 会话类型常量 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, err := supportgroup.PickSupportAdmin(tx, supportgroup.GroupCodeOwnerOnboarding) if err != nil { return nil, err } if supportID <= 0 { 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 } if err := createQrCodeDeliveryTask(tx, conversation.ID); err != nil { return nil, err } } // 7. 库存预警检查 // 站内信在事务内写入;外部推送在事务提交后发送,避免事务回滚时误发。 // 这里通过 goroutine 延迟 1 秒发送,给事务提交留出时间。 // 极端情况下事务回滚仍可能误发,但仅是通知层面的轻微不一致,可接受。 if qrcode != nil { if alert, err := checkQrCodeStockAndAlert(tx, &conversation); err == nil && alert != nil { go func(a pushAlert) { time.Sleep(time.Second) for _, p := range a.providers { _ = p.Send(context.Background(), a.message) } }(*alert) } } 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, LastReadAt: &now, } if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil { return err } // 3. 支付后由卖号组客服接入跟进交接和售后。 handoffSupportID, err := supportgroup.PickSupportAdmin(tx, supportgroup.GroupCodeRenterHandoff) if err != nil { return err } if handoffSupportID > 0 { support := model.ChatParticipant{ ConversationID: conv.ID, ParticipantType: "admin", ParticipantID: handoffSupportID, Role: "support", JoinedAt: now, } if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&support).Error; err != nil { return err } } // 4. 发系统消息 message := "租客已加入群聊(订单 " + orderNo + ")" if handoffSupportID > 0 { message += ",卖号组客服已接入" } return sendSystemMessage(tx, conv.ID, message) } // RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。 func RemoveRenterFromListingConversation(tx *gorm.DB, listingID uint64, renterID uint64) (bool, 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 false, nil } return false, 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 false, result.Error } // 3. 发系统消息(如果确实删除了) if result.RowsAffected > 0 { message := "订单已结束,租客已退出群聊" if err := sendSystemMessage(tx, conv.ID, message); err != nil { return false, err } return true, nil } return false, 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) (*model.ChatMessage, 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 nil, err } // 更新会话的最后消息 if err := updateConversationLastMessage(tx, conversationID, &message); err != nil { return nil, err } return &message, nil } 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 createQrCodeDeliveryTask(tx *gorm.DB, conversationID uint64) error { task := model.ChatQrCodeDeliveryTask{ ConversationID: conversationID, Status: "pending", } return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&task).Error } // pushAlert 事务提交后需要发送的外部推送。 type pushAlert struct { providers []push.Provider message push.Message } func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation) (*pushAlert, error) { // 从 push_rules 获取规则 threshold := int64(5) ruleEnabled := true messageTemplate := "企业微信群二维码库存不足(剩余 {{.Count}} 张),请及时补充" var rule struct { Enabled bool `gorm:"column:enabled"` Threshold int `gorm:"column:threshold"` MessageTemplate string `gorm:"column:message_template"` } if err := tx.Table("push_rules").Where("event = ?", "qrcode_low_stock").First(&rule).Error; err == nil { ruleEnabled = rule.Enabled threshold = int64(rule.Threshold) if rule.MessageTemplate != "" { messageTemplate = rule.MessageTemplate } } if !ruleEnabled { return nil, nil } // 统计未使用的二维码数量 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 nil, err } if count > threshold { return nil, nil } // 低于阈值:写入站内信(事务内) 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 nil, err } alertContent := strings.ReplaceAll(messageTemplate, "{{.Count}}", 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 nil, err } } // 返回外部推送数据,由调用方在事务提交后发送 providers := loadPushProviders(tx) if len(providers) == 0 { return nil, nil } return &pushAlert{ providers: providers, message: push.Message{ Title: "二维码库存预警", Content: alertContent, }, }, nil } // loadPushProviders 从 push_channels 表读取启用的渠道并创建 providers。 func loadPushProviders(tx *gorm.DB) []push.Provider { var providers []push.Provider var channels []struct { Type string `gorm:"column:type"` Config json.RawMessage `gorm:"column:config"` } if err := tx.Table("push_channels").Where("enabled = ?", true).Find(&channels).Error; err != nil { return providers } for _, ch := range channels { switch ch.Type { case "bark": var cfg struct { DeviceKey string `json:"device_key"` Server string `json:"server"` } if err := json.Unmarshal(ch.Config, &cfg); err != nil || cfg.DeviceKey == "" { continue } bark, err := push.NewBarkProvider(push.BarkConfig{ DeviceKey: cfg.DeviceKey, Server: cfg.Server, }) if err == nil { providers = append(providers, bark) } case "wpush": var cfg struct { APIKey string `json:"api_key"` } if err := json.Unmarshal(ch.Config, &cfg); err != nil || cfg.APIKey == "" { continue } wpush, err := push.NewWPushProvider(push.WPushConfig{APIKey: cfg.APIKey}) if err == nil { providers = append(providers, wpush) } } } return providers }