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)
-
-
+