diff --git a/backend/internal/modules/chat/handler_qrcode.go b/backend/internal/modules/chat/handler_qrcode.go index 1979e1a..39fc9c7 100644 --- a/backend/internal/modules/chat/handler_qrcode.go +++ b/backend/internal/modules/chat/handler_qrcode.go @@ -97,6 +97,10 @@ func (h *Handler) UpdateQrCodeHandler(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "二维码不存在"}) return } + if err == ErrQrCodeCannotReenable { + c.JSON(http.StatusBadRequest, gin.H{"error": "已发放的二维码不能改回待用"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/backend/internal/modules/chat/qrcode.go b/backend/internal/modules/chat/qrcode.go index 08ded98..bad52be 100644 --- a/backend/internal/modules/chat/qrcode.go +++ b/backend/internal/modules/chat/qrcode.go @@ -18,8 +18,9 @@ const ( ) var ( - ErrQrCodeNotFound = errors.New("二维码不存在") - ErrQrCodeCannotDelete = errors.New("已使用的二维码不能删除") + ErrQrCodeNotFound = errors.New("二维码不存在") + ErrQrCodeCannotDelete = errors.New("已使用的二维码不能删除") + ErrQrCodeCannotReenable = errors.New("已发放的二维码不能改回待用") ) // CreateQrCodeRequest 创建二维码请求 @@ -172,6 +173,14 @@ func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) { // 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 { @@ -185,6 +194,9 @@ func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCo 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 { @@ -197,17 +209,17 @@ func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCo return nil } - result := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).Where("id = ?", id).Updates(updates) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return ErrQrCodeNotFound + 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 diff --git a/backend/internal/modules/chat/qrcode_test.go b/backend/internal/modules/chat/qrcode_test.go new file mode 100644 index 0000000..a8421b2 --- /dev/null +++ b/backend/internal/modules/chat/qrcode_test.go @@ -0,0 +1,105 @@ +package chat + +import ( + "errors" + "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{}); err != nil { + t.Fatalf("数据库迁移失败: %v", err) + } + return db +} + +func TestUpdateQrCodeRejectsUsedToUnused(t *testing.T) { + db := setupQrCodeTestDB(t) + repo := NewRepository(db, 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) + 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) + 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) + } +} diff --git a/frontend/src/features/admin/views/AdminQrCodePoolView.vue b/frontend/src/features/admin/views/AdminQrCodePoolView.vue index 5b18dd2..6f4db24 100644 --- a/frontend/src/features/admin/views/AdminQrCodePoolView.vue +++ b/frontend/src/features/admin/views/AdminQrCodePoolView.vue @@ -47,6 +47,7 @@ const editForm = reactive({ note: '', status: 'unused' as QrCodeStatus, expires_at: '' as string, + was_issued: false, }) const statusOptions: { label: string; value: QrCodeStatus | ''; type: string }[] = [ @@ -64,6 +65,12 @@ const statusMap = computed(() => { return m }) +const editStatusOptions = computed(() => + editForm.was_issued + ? statusOptions.filter(option => option.value === 'used' || option.value === 'disabled') + : statusOptions.filter(option => option.value === 'unused' || option.value === 'disabled') +) + const maxBatchUploadCount = 20 const uploading = computed(() => uploadPendingCount.value > 0) const uploadBusy = computed(() => uploading.value || uploadSaving.value) @@ -172,6 +179,7 @@ function openEdit(row: ChatQrCode) { editForm.note = row.note editForm.status = row.status editForm.expires_at = row.expires_at ? row.expires_at.replace('T', ' ').slice(0, 16) : '' + editForm.was_issued = row.status === 'used' || Boolean(row.conversation_id || row.used_at) editVisible.value = true } @@ -451,8 +459,12 @@ onMounted(reloadAll) - - +