禁止已发放二维码回退待用

This commit is contained in:
yml
2026-06-18 09:59:06 +08:00
parent 58e071d157
commit 7934141ba2
4 changed files with 143 additions and 10 deletions
@@ -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
}
+20 -8
View File
@@ -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
@@ -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)
}
}
@@ -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)
</el-form-item>
<el-form-item label="状态">
<el-select v-model="editForm.status" style="width: 100%">
<el-option label="待用" value="unused" />
<el-option label="已停用" value="disabled" />
<el-option
v-for="option in editStatusOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="备注">