package chat import ( "errors" "strings" "testing" "time" "hfb_sys/backend/internal/model" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) func setupQrCodeTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("无法创建测试数据库: %v", err) } 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 } func TestGetQrCodeOCRConfigReadsSystemConfig(t *testing.T) { db := setupQrCodeTestDB(t) repo := NewRepository(db, nil, nil) rows := []model.SystemConfig{ {Key: qrCodeOCRTokenConfigKey, Value: "test-token"}, {Key: qrCodeOCRJobURLConfigKey, Value: "https://example.test/ocr/jobs"}, {Key: qrCodeOCRModelConfigKey, Value: "PaddleOCR-Test"}, } if err := db.Create(&rows).Error; err != nil { t.Fatalf("创建系统配置失败: %v", err) } config, err := repo.GetQrCodeOCRConfig(t.Context()) if err != nil { t.Fatalf("读取 OCR 配置失败: %v", err) } if config.Token != "test-token" { t.Fatalf("token = %q", config.Token) } if config.JobURL != "https://example.test/ocr/jobs" { t.Fatalf("job_url = %q", config.JobURL) } if config.Model != "PaddleOCR-Test" { t.Fatalf("model = %q", config.Model) } } func TestListQrCodesIncludesBoundConversationTitle(t *testing.T) { db := setupQrCodeTestDB(t) repo := NewRepository(db, nil, nil) conversation := model.ChatConversation{ Title: "账号群 L202606180001", Type: ConversationTypeListingGroup, Status: "active", } if err := db.Create(&conversation).Error; err != nil { t.Fatalf("创建群聊失败: %v", err) } qrcode := model.ChatQrCode{ ImageURL: "/api/files/object?key=qrcode/group.png", GroupName: "王大锤-鼠鼠跑刀一群", Status: QrCodeStatusUsed, ConversationID: &conversation.ID, CreatedBy: 1, } if err := db.Create(&qrcode).Error; err != nil { t.Fatalf("创建二维码失败: %v", err) } items, total, err := repo.ListQrCodes(t.Context(), QrCodeListRequest{Page: 1, Limit: 20}) if err != nil { t.Fatalf("查询二维码失败: %v", err) } if total != 1 || len(items) != 1 { t.Fatalf("total = %d, len = %d, want 1", total, len(items)) } if items[0].GroupName != "王大锤-鼠鼠跑刀一群" { t.Fatalf("群名 = %q", items[0].GroupName) } if items[0].BoundConversationTitle != "账号群 L202606180001" { t.Fatalf("绑定群名 = %q", items[0].BoundConversationTitle) } } func TestListQrCodesFilters(t *testing.T) { db := setupQrCodeTestDB(t) repo := NewRepository(db, nil, nil) now := time.Now() expiredAt := now.Add(-time.Hour) validAt := now.Add(7 * 24 * time.Hour) expiringAt := now.Add(2 * time.Hour) conversation := model.ChatConversation{ Title: "账号群 绑定测试", Type: ConversationTypeListingGroup, Status: "active", } if err := db.Create(&conversation).Error; err != nil { t.Fatalf("创建群聊失败: %v", err) } qrcodes := []model.ChatQrCode{ { ImageURL: "/api/files/object?key=qrcode/unused-a.png", GroupName: "待用群A", Status: QrCodeStatusUnused, CreatedBy: 1, Note: "六月首批", ExpiresAt: &validAt, CreatedAt: now.Add(-48 * time.Hour), }, { ImageURL: "/api/files/object?key=qrcode/used-b.png", GroupName: "已发群B", Status: QrCodeStatusUsed, ConversationID: &conversation.ID, CreatedBy: 1, Note: "特殊备注", WecomRenamed: true, ExpiresAt: &expiredAt, CreatedAt: now.Add(-2 * time.Hour), }, { ImageURL: "/api/files/object?key=qrcode/permanent-c.png", GroupName: "永久群C", Status: QrCodeStatusDisabled, CreatedBy: 1, Note: "长期备用", CreatedAt: now.Add(-24 * time.Hour), }, { ImageURL: "/api/files/object?key=qrcode/expiring-d.png", GroupName: "临期群D", Status: QrCodeStatusUnused, CreatedBy: 1, Note: "三天内到期", ExpiresAt: &expiringAt, CreatedAt: now.Add(-30 * time.Minute), }, } if err := db.Create(&qrcodes).Error; err != nil { t.Fatalf("创建二维码失败: %v", err) } assertListIDs := func(name string, req QrCodeListRequest, want ...uint64) { t.Helper() req.Page = 1 req.Limit = 20 items, total, err := repo.ListQrCodes(t.Context(), req) if err != nil { t.Fatalf("%s 查询二维码失败: %v", name, err) } if total != int64(len(want)) || len(items) != len(want) { t.Fatalf("%s total = %d, len = %d, want %d", name, total, len(items), len(want)) } for i, id := range want { if items[i].ID != id { t.Fatalf("%s 第 %d 条 ID = %d, want %d", name, i, items[i].ID, id) } } } assertListIDs("状态", QrCodeListRequest{Status: QrCodeStatusUnused}, qrcodes[3].ID, qrcodes[0].ID) assertListIDs("关键词群名", QrCodeListRequest{Keyword: "待用群"}, qrcodes[0].ID) assertListIDs("关键词备注", QrCodeListRequest{Keyword: "特殊备注"}, qrcodes[1].ID) assertListIDs("关键词绑定群", QrCodeListRequest{Keyword: "绑定测试"}, qrcodes[1].ID) assertListIDs("企微改名", QrCodeListRequest{WecomRenamed: "true"}, qrcodes[1].ID) assertListIDs("未绑定", QrCodeListRequest{Bound: "false"}, qrcodes[3].ID, qrcodes[2].ID, qrcodes[0].ID) assertListIDs("已过期", QrCodeListRequest{ExpireStatus: "expired"}, qrcodes[1].ID) assertListIDs("永久有效", QrCodeListRequest{ExpireStatus: "permanent"}, qrcodes[2].ID) assertListIDs("即将过期", QrCodeListRequest{ExpireStatus: "expiring_soon"}, qrcodes[3].ID) assertListIDs( "上传时间", QrCodeListRequest{ CreatedStart: now.Add(-3 * time.Hour).Format(time.RFC3339), CreatedEnd: now.Add(-90 * time.Minute).Format(time.RFC3339), }, qrcodes[1].ID, ) } func TestParseQrCodeGroupNameCandidates(t *testing.T) { rawText := strings.Join([]string{ "使用微信或企业微信扫码加入", "王大锤-鼠鼠跑刀一群", "这二维码7天内有效", }, "\n") candidates := parseQrCodeGroupNameCandidates(rawText) if len(candidates) == 0 { t.Fatal("未解析出群名候选") } if candidates[0] != "王大锤-鼠鼠跑刀一群" { t.Fatalf("首个候选 = %q", candidates[0]) } } func TestUpdateQrCodeRejectsUsedToUnused(t *testing.T) { db := setupQrCodeTestDB(t) repo := NewRepository(db, nil, nil) now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC) conversationID := uint64(1001) qrcode := model.ChatQrCode{ ImageURL: "/api/files/object?key=qrcode/used.png", Status: QrCodeStatusUsed, ConversationID: &conversationID, UsedAt: &now, CreatedBy: 1, } if err := db.Create(&qrcode).Error; err != nil { t.Fatalf("创建二维码失败: %v", err) } status := QrCodeStatusUnused err := repo.UpdateQrCode(t.Context(), qrcode.ID, UpdateQrCodeRequest{Status: &status}) if !errors.Is(err, ErrQrCodeCannotReenable) { t.Fatalf("error = %v, want ErrQrCodeCannotReenable", err) } var saved model.ChatQrCode if err := db.First(&saved, qrcode.ID).Error; err != nil { t.Fatalf("查询二维码失败: %v", err) } if saved.Status != QrCodeStatusUsed { t.Fatalf("二维码状态 = %q, want %q", saved.Status, QrCodeStatusUsed) } } func TestUpdateQrCodeRejectsIssuedDisabledToUnused(t *testing.T) { db := setupQrCodeTestDB(t) repo := NewRepository(db, nil, nil) conversationID := uint64(1002) qrcode := model.ChatQrCode{ ImageURL: "/api/files/object?key=qrcode/disabled.png", Status: QrCodeStatusDisabled, ConversationID: &conversationID, CreatedBy: 1, } if err := db.Create(&qrcode).Error; err != nil { t.Fatalf("创建二维码失败: %v", err) } status := QrCodeStatusUnused err := repo.UpdateQrCode(t.Context(), qrcode.ID, UpdateQrCodeRequest{Status: &status}) if !errors.Is(err, ErrQrCodeCannotReenable) { t.Fatalf("error = %v, want ErrQrCodeCannotReenable", err) } } func TestUpdateQrCodeAllowsUnusedToDisabled(t *testing.T) { db := setupQrCodeTestDB(t) repo := NewRepository(db, nil, nil) qrcode := model.ChatQrCode{ ImageURL: "/api/files/object?key=qrcode/unused.png", Status: QrCodeStatusUnused, CreatedBy: 1, } if err := db.Create(&qrcode).Error; err != nil { t.Fatalf("创建二维码失败: %v", err) } status := QrCodeStatusDisabled if err := repo.UpdateQrCode(t.Context(), qrcode.ID, UpdateQrCodeRequest{Status: &status}); err != nil { t.Fatalf("更新二维码失败: %v", err) } var saved model.ChatQrCode if err := db.First(&saved, qrcode.ID).Error; err != nil { t.Fatalf("查询二维码失败: %v", err) } if saved.Status != QrCodeStatusDisabled { t.Fatalf("二维码状态 = %q, want %q", saved.Status, QrCodeStatusDisabled) } } func TestUpdateQrCodeGroupNameAndRenameFlag(t *testing.T) { db := setupQrCodeTestDB(t) repo := NewRepository(db, nil, nil) qrcode := model.ChatQrCode{ ImageURL: "/api/files/object?key=qrcode/unused.png", Status: QrCodeStatusUnused, CreatedBy: 1, } if err := db.Create(&qrcode).Error; err != nil { t.Fatalf("创建二维码失败: %v", err) } groupName := "王大锤-鼠鼠跑刀一群" renamed := true if err := repo.UpdateQrCode(t.Context(), qrcode.ID, UpdateQrCodeRequest{ GroupName: &groupName, WecomRenamed: &renamed, }); err != nil { t.Fatalf("更新二维码失败: %v", err) } var saved model.ChatQrCode if err := db.First(&saved, qrcode.ID).Error; err != nil { t.Fatalf("查询二维码失败: %v", err) } if saved.GroupName != groupName { t.Fatalf("群名 = %q, want %q", saved.GroupName, groupName) } if !saved.WecomRenamed { t.Fatal("企微改名标记未保存") } } func TestDeleteQrCodeAllowsIssuedQrCode(t *testing.T) { db := setupQrCodeTestDB(t) repo := NewRepository(db, nil, nil) now := time.Date(2026, 6, 19, 17, 8, 0, 0, time.UTC) conversationID := uint64(1003) qrcode := model.ChatQrCode{ ImageURL: "/api/files/object?key=qrcode/issued.png", Status: QrCodeStatusUsed, ConversationID: &conversationID, UsedAt: &now, CreatedBy: 1, } if err := db.Create(&qrcode).Error; err != nil { t.Fatalf("创建二维码失败: %v", err) } if err := repo.DeleteQrCode(t.Context(), qrcode.ID); err != nil { t.Fatalf("删除已发放二维码失败: %v", err) } var count int64 if err := db.Model(&model.ChatQrCode{}).Where("id = ?", qrcode.ID).Count(&count).Error; err != nil { t.Fatalf("统计二维码失败: %v", err) } if count != 0 { t.Fatalf("二维码数量 = %d, want 0", count) } } func TestBatchDeleteQrCodes(t *testing.T) { db := setupQrCodeTestDB(t) repo := NewRepository(db, nil, nil) now := time.Date(2026, 6, 19, 17, 8, 0, 0, time.UTC) conversationID := uint64(1004) qrcodes := []model.ChatQrCode{ { ImageURL: "/api/files/object?key=qrcode/unused.png", Status: QrCodeStatusUnused, CreatedBy: 1, }, { ImageURL: "/api/files/object?key=qrcode/used.png", Status: QrCodeStatusUsed, ConversationID: &conversationID, UsedAt: &now, CreatedBy: 1, }, { ImageURL: "/api/files/object?key=qrcode/keep.png", Status: QrCodeStatusDisabled, CreatedBy: 1, }, } if err := db.Create(&qrcodes).Error; err != nil { t.Fatalf("创建二维码失败: %v", err) } deleted, err := repo.BatchDeleteQrCodes(t.Context(), []uint64{qrcodes[0].ID, qrcodes[1].ID}) if err != nil { t.Fatalf("批量删除二维码失败: %v", err) } if deleted != 2 { t.Fatalf("删除数量 = %d, want 2", deleted) } var remaining []model.ChatQrCode if err := db.Order("id ASC").Find(&remaining).Error; err != nil { t.Fatalf("查询剩余二维码失败: %v", err) } if len(remaining) != 1 || remaining[0].ID != qrcodes[2].ID { t.Fatalf("剩余二维码 = %+v, want id %d", remaining, qrcodes[2].ID) } } 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, 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) } }