diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go index e25ecff..d4ca2f1 100644 --- a/backend/internal/model/chat.go +++ b/backend/internal/model/chat.go @@ -76,6 +76,21 @@ func (ChatQrCode) TableName() string { return "chat_qrcode_pool" } +type ChatQrCodeDeliveryTask struct { + ID uint64 `gorm:"primaryKey" json:"id"` + ConversationID uint64 `gorm:"column:conversation_id;not null;uniqueIndex" json:"conversation_id"` + Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` + QrCodeID *uint64 `gorm:"column:qrcode_id;index" json:"qrcode_id"` + ErrorMessage string `gorm:"column:error_message;size:255;not null;default:''" json:"error_message"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SentAt *time.Time `gorm:"column:sent_at" json:"sent_at"` +} + +func (ChatQrCodeDeliveryTask) TableName() string { + return "chat_qrcode_delivery_tasks" +} + type ChatSupportGroup struct { ID uint64 `gorm:"primaryKey" json:"id"` Code string `gorm:"size:64;not null;uniqueIndex" json:"code"` diff --git a/backend/internal/modules/chat/listing_group.go b/backend/internal/modules/chat/listing_group.go index 6ceaf33..308b1c4 100644 --- a/backend/internal/modules/chat/listing_group.go +++ b/backend/internal/modules/chat/listing_group.go @@ -112,7 +112,7 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferr // 6. 发二维码图片(如果有) if qrcodeImageURL != "" { - if err := sendQrCodeImage(tx, conversation.ID, qrcodeImageURL); err != nil { + if _, err := sendQrCodeImage(tx, conversation.ID, qrcodeImageURL); err != nil { return nil, err } } else { @@ -121,6 +121,9 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferr if err := sendSystemMessage(tx, conversation.ID, noQrTip); err != nil { return nil, err } + if err := createQrCodeDeliveryTask(tx, conversation.ID); err != nil { + return nil, err + } } // 7. 库存预警检查 @@ -262,7 +265,7 @@ func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error return updateConversationLastMessage(tx, conversationID, &message) } -func sendQrCodeImage(tx *gorm.DB, conversationID uint64, imageURL string) error { +func sendQrCodeImage(tx *gorm.DB, conversationID uint64, imageURL string) (*model.ChatMessage, error) { content := "👇 请扫码加入企业微信群" message := model.ChatMessage{ @@ -275,11 +278,14 @@ func sendQrCodeImage(tx *gorm.DB, conversationID uint64, imageURL string) error } if err := tx.Create(&message).Error; err != nil { - return err + return nil, err } // 更新会话的最后消息 - return updateConversationLastMessage(tx, conversationID, &message) + 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 { @@ -326,6 +332,14 @@ func markQrCodeAsUsed(tx *gorm.DB, qrcodeID uint64, conversationID uint64) error }).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 diff --git a/backend/internal/modules/chat/qrcode.go b/backend/internal/modules/chat/qrcode.go index 59694da..e3d7489 100644 --- a/backend/internal/modules/chat/qrcode.go +++ b/backend/internal/modules/chat/qrcode.go @@ -8,6 +8,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/chathub" ) // QrCodeStatus 二维码状态常量 @@ -83,24 +84,35 @@ type QrCodeOCRConfig struct { // CreateQrCode 创建二维码 func (r *Repository) CreateQrCode(ctx context.Context, adminID uint64, req CreateQrCodeRequest) (*model.ChatQrCode, error) { - qrcode := model.ChatQrCode{ - ImageURL: req.ImageURL, - GroupName: req.GroupName, - Status: QrCodeStatusUnused, - CreatedBy: adminID, - Note: req.Note, - ExpiresAt: req.ExpiresAt, - } + 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 - } + // 如果未指定过期时间,默认7天后过期 + if qrcode.ExpiresAt == nil { + expires := time.Now().Add(7 * 24 * time.Hour) + qrcode.ExpiresAt = &expires + } - if err := r.db.WithContext(ctx).Create(&qrcode).Error; err != nil { + 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 } @@ -108,26 +120,36 @@ func (r *Repository) CreateQrCode(ctx context.Context, adminID uint64, req Creat // 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) - for _, item := range req.Items { - qr := model.ChatQrCode{ - ImageURL: item.ImageURL, - GroupName: item.GroupName, - Status: QrCodeStatusUnused, - CreatedBy: adminID, - Note: item.Note, - ExpiresAt: item.ExpiresAt, + 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 qr.ExpiresAt == nil { - qr.ExpiresAt = &defaultExpires - } - qrcodes = append(qrcodes, qr) - } - if err := r.db.WithContext(ctx).Create(&qrcodes).Error; err != nil { + 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 } @@ -342,3 +364,82 @@ func (r *Repository) markQrCodeAsUsed(tx *gorm.DB, qrcodeID uint64, conversation "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) + } +} diff --git a/backend/internal/modules/chat/qrcode_test.go b/backend/internal/modules/chat/qrcode_test.go index d3faf2e..9e2ae31 100644 --- a/backend/internal/modules/chat/qrcode_test.go +++ b/backend/internal/modules/chat/qrcode_test.go @@ -21,7 +21,19 @@ func setupQrCodeTestDB(t *testing.T) *gorm.DB { if err != nil { t.Fatalf("无法创建测试数据库: %v", err) } - if err := db.AutoMigrate(&model.ChatQrCode{}, &model.ChatConversation{}, &model.SystemConfig{}); err != nil { + if err := db.AutoMigrate( + &model.ChatQrCode{}, + &model.ChatConversation{}, + &model.ChatParticipant{}, + &model.ChatMessage{}, + &model.ChatQrCodeDeliveryTask{}, + &model.ChatSupportGroup{}, + &model.ChatSupportGroupMember{}, + &model.AdminUser{}, + &model.AdminUserRole{}, + &model.Role{}, + &model.SystemConfig{}, + ); err != nil { t.Fatalf("数据库迁移失败: %v", err) } return db @@ -218,3 +230,86 @@ func TestUpdateQrCodeGroupNameAndRenameFlag(t *testing.T) { t.Fatal("企微改名标记未保存") } } + +func TestEnsureListingConversationCreatesQrCodeDeliveryTaskWhenStockEmpty(t *testing.T) { + db := setupQrCodeTestDB(t) + listing := model.RentalListing{ + ID: 101, + ListingNo: "L202606260001", + OwnerID: 10, + } + + if err := db.Transaction(func(tx *gorm.DB) error { + _, err := EnsureListingConversation(tx, listing, 0) + return err + }); err != nil { + t.Fatalf("建发布群失败: %v", err) + } + + var task model.ChatQrCodeDeliveryTask + if err := db.First(&task).Error; err != nil { + t.Fatalf("未创建补发任务: %v", err) + } + if task.Status != "pending" { + t.Fatalf("补发任务状态 = %q, want pending", task.Status) + } +} + +func TestBatchCreateQrCodeDeliversPendingTask(t *testing.T) { + db := setupQrCodeTestDB(t) + repo := NewRepository(db, nil) + conversation := model.ChatConversation{ + Title: "账号群 L202606260002", + Type: ConversationTypeListingGroup, + Status: "active", + } + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("创建群聊失败: %v", err) + } + task := model.ChatQrCodeDeliveryTask{ + ConversationID: conversation.ID, + Status: "pending", + } + if err := db.Create(&task).Error; err != nil { + t.Fatalf("创建补发任务失败: %v", err) + } + + _, err := repo.BatchCreateQrCode(t.Context(), 1, BatchCreateQrCodeRequest{ + Items: []CreateQrCodeRequest{ + {ImageURL: "/api/files/object?key=qrcode/replenish.png", GroupName: "补发群"}, + }, + }) + if err != nil { + t.Fatalf("批量创建二维码失败: %v", err) + } + + var savedTask model.ChatQrCodeDeliveryTask + if err := db.First(&savedTask, task.ID).Error; err != nil { + t.Fatalf("查询补发任务失败: %v", err) + } + if savedTask.Status != "sent" { + t.Fatalf("补发任务状态 = %q, want sent", savedTask.Status) + } + if savedTask.QrCodeID == nil { + t.Fatal("补发任务未记录二维码 ID") + } + if savedTask.SentAt == nil { + t.Fatal("补发任务未记录 sent_at") + } + + var qrcode model.ChatQrCode + if err := db.First(&qrcode, *savedTask.QrCodeID).Error; err != nil { + t.Fatalf("查询二维码失败: %v", err) + } + if qrcode.Status != QrCodeStatusUsed { + t.Fatalf("二维码状态 = %q, want used", qrcode.Status) + } + if qrcode.ConversationID == nil || *qrcode.ConversationID != conversation.ID { + t.Fatalf("二维码绑定会话 = %v, want %d", qrcode.ConversationID, conversation.ID) + } + + var message model.ChatMessage + if err := db.Where("conversation_id = ? AND content_type = ?", conversation.ID, "image").First(&message).Error; err != nil { + t.Fatalf("未生成二维码图片消息: %v", err) + } +} diff --git a/backend/migrations/000015_qrcode_delivery_tasks.sql b/backend/migrations/000015_qrcode_delivery_tasks.sql new file mode 100644 index 0000000..f538409 --- /dev/null +++ b/backend/migrations/000015_qrcode_delivery_tasks.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- +goose StatementBegin + +CREATE TABLE IF NOT EXISTS chat_qrcode_delivery_tasks ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + conversation_id BIGINT UNSIGNED NOT NULL COMMENT '待补发二维码的会话ID', + status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '任务状态: pending待补发, sent已补发, failed失败', + qrcode_id BIGINT UNSIGNED NULL COMMENT '补发使用的二维码ID', + error_message VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + sent_at DATETIME NULL COMMENT '补发完成时间', + UNIQUE KEY uk_qrcode_delivery_conversation (conversation_id), + KEY idx_qrcode_delivery_status_created (status, created_at), + KEY idx_qrcode_delivery_qrcode (qrcode_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='发布群二维码补发任务'; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP TABLE IF EXISTS chat_qrcode_delivery_tasks; + +-- +goose StatementEnd diff --git a/frontend/public/dec799fa0d956e00a53e156074769408.txt b/frontend/public/dec799fa0d956e00a53e156074769408.txt new file mode 100644 index 0000000..eb98aae --- /dev/null +++ b/frontend/public/dec799fa0d956e00a53e156074769408.txt @@ -0,0 +1 @@ +f4b37a1b65df663bd30ab33e23a8e90472595792 \ No newline at end of file diff --git a/frontend/src/features/admin/views/AdminQrCodePoolView.vue b/frontend/src/features/admin/views/AdminQrCodePoolView.vue index bbb3833..a78e90f 100644 --- a/frontend/src/features/admin/views/AdminQrCodePoolView.vue +++ b/frontend/src/features/admin/views/AdminQrCodePoolView.vue @@ -486,14 +486,17 @@ onMounted(reloadAll) /> - + @@ -819,7 +822,7 @@ onMounted(reloadAll) width: 8%; } -.qrcode-table :deep(.expire-col) { +.qrcode-table :deep(.time-col) { width: 12%; } @@ -843,9 +846,15 @@ onMounted(reloadAll) font-weight: 500; } -.expire-cell { +.time-stack { + display: inline-flex; + flex-direction: column; + gap: 2px; color: #5f6b7a; - font-size: 13px; + font-size: 12px; + line-height: 18px; + text-align: left; + white-space: nowrap; } .copy-cell-button {