mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 06:56:36 +08:00
fix(storage): apply driver switch on save and repoint upload records
Saving storage settings now activates the selected driver immediately
instead of staging it until migration. When the driver changes, existing
upload rows are repointed to the new storage_driver so /f/{id} reads use
the correct backend. ForDriver can also open non-active drivers from the
saved multi-backend config.
This commit is contained in:
@@ -43,7 +43,6 @@ description: "Wavelet 项目专用:新增或修改 Asynq 异步任务、后台
|
||||
- 失败返回 error,由任务框架处理状态和重试。
|
||||
- 不要吞掉关键错误。
|
||||
- 复杂 SQL 放到 `internal/model/` 或模块内的业务服务层(如 `internal/apps/<module>/service.go` 或 `logics.go`)。
|
||||
- 新增 Go 文件后检查许可证头,必要时运行 `make license`。
|
||||
|
||||
### 注册
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet"
|
||||
import services, {formatFileSize, getFileUrl} from "@/lib/services"
|
||||
import {FileImagePreview} from "@/components/common/file-image-preview"
|
||||
import services, {formatFileSize} from "@/lib/services"
|
||||
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
|
||||
|
||||
/* ─── 工具函数 ─────────────────────────────────────────── */
|
||||
@@ -288,16 +289,12 @@ export function FileList() {
|
||||
<TableCell className="py-3">
|
||||
<div className="flex items-center justify-center size-9 rounded-lg bg-muted/40 overflow-hidden border">
|
||||
{file.mime_type.startsWith("image/") ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getFileUrl(file.id, "low") ?? undefined}
|
||||
<FileImagePreview
|
||||
fileId={file.id}
|
||||
alt={file.file_name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
quality="low"
|
||||
variant="compact"
|
||||
className="size-full object-cover"
|
||||
onError={(e) => {
|
||||
;(e.currentTarget as HTMLImageElement).style.display = "none"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
getFileIcon(file.mime_type, "size-4.5")
|
||||
@@ -406,13 +403,12 @@ export function FileList() {
|
||||
{/* 大图/格式预览 */}
|
||||
<div className="flex items-center justify-center h-48 rounded-xl bg-muted/30 border border-dashed overflow-hidden p-2">
|
||||
{detailTarget.mime_type.startsWith("image/") ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getFileUrl(detailTarget.id, "low") ?? undefined}
|
||||
<FileImagePreview
|
||||
fileId={detailTarget.id}
|
||||
alt={detailTarget.file_name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
quality="low"
|
||||
className="max-h-full max-w-full object-contain rounded-lg shadow-sm"
|
||||
fallbackClassName="min-h-32 w-full rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
|
||||
@@ -316,7 +316,7 @@ export function StorageConfigTab() {
|
||||
<CardFooter className="justify-end gap-2 flex-col sm:flex-row items-end sm:items-center border-t border-dashed pt-4 mt-6">
|
||||
{config.driver !== query.data?.config.driver && (
|
||||
<span className="text-xs text-amber-500 mr-auto text-left max-w-md">
|
||||
⚠️ 您已切换存储类型。请先点击“保存配置”保存各存储端的配置凭据,然后点击“开始迁移”手动执行文件迁移。
|
||||
⚠️ 您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。
|
||||
</span>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {ImageOff} from "lucide-react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
import {getFileUrl, type ImageQuality} from "@/lib/services/upload/utils"
|
||||
|
||||
type FileImagePreviewProps = {
|
||||
fileId: string | number
|
||||
alt: string
|
||||
quality?: ImageQuality
|
||||
className?: string
|
||||
fallbackClassName?: string
|
||||
variant?: "compact" | "default"
|
||||
}
|
||||
|
||||
export function FileImagePreview({
|
||||
fileId,
|
||||
alt,
|
||||
quality = "low",
|
||||
className,
|
||||
fallbackClassName,
|
||||
variant = "default",
|
||||
}: FileImagePreviewProps) {
|
||||
const [failed, setFailed] = React.useState(false)
|
||||
const src = getFileUrl(fileId, quality)
|
||||
|
||||
React.useEffect(() => {
|
||||
setFailed(false)
|
||||
}, [fileId, quality])
|
||||
|
||||
if (!src || failed) {
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={`${alt} 加载失败`}
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-1 bg-muted/50 text-muted-foreground",
|
||||
fallbackClassName,
|
||||
)}
|
||||
>
|
||||
<ImageOff className={cn("shrink-0 opacity-70", variant === "compact" ? "size-3.5" : "size-5")} />
|
||||
{variant === "default" && (
|
||||
<span className="text-[10px] leading-none">加载失败</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={className}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useQuery, useMutation, useQueryClient} from "@tanstack/react-query"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {
|
||||
Upload,
|
||||
Trash2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Search,
|
||||
FileText,
|
||||
FileImage,
|
||||
FileVideo,
|
||||
FileAudio,
|
||||
FileArchive,
|
||||
Loader2,
|
||||
FileAudio,
|
||||
FileImage,
|
||||
FileText,
|
||||
FileVideo,
|
||||
Globe,
|
||||
Loader2,
|
||||
Lock,
|
||||
Plus,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
Search,
|
||||
Trash2,
|
||||
Upload
|
||||
} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
|
||||
@@ -36,7 +36,8 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
import services, {formatFileSize, getFileUrl} from "@/lib/services"
|
||||
import {FileImagePreview} from "@/components/common/file-image-preview"
|
||||
import services, {formatFileSize} from "@/lib/services"
|
||||
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
|
||||
|
||||
/* ─── 工具函数 ─────────────────────────────────────────── */
|
||||
@@ -238,16 +239,12 @@ export function UserFileManager() {
|
||||
{/* 文件预览/图标区 */}
|
||||
<div className="h-36 bg-muted/30 border-b border-dashed relative flex items-center justify-center overflow-hidden p-2 group-hover:bg-muted/10 transition-colors">
|
||||
{file.mime_type.startsWith("image/") ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getFileUrl(file.id, "medium") ?? undefined}
|
||||
<FileImagePreview
|
||||
fileId={file.id}
|
||||
alt={file.file_name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
quality="medium"
|
||||
className="max-h-full max-w-full object-contain rounded-md shadow-xs transition-transform duration-350 group-hover:scale-103"
|
||||
onError={(e) => {
|
||||
;(e.currentTarget as HTMLImageElement).style.display = "none"
|
||||
}}
|
||||
fallbackClassName="min-h-full w-full rounded-md"
|
||||
/>
|
||||
) : (
|
||||
getFileIcon(file.mime_type)
|
||||
@@ -283,7 +280,7 @@ export function UserFileManager() {
|
||||
|
||||
<div className="flex items-center justify-between pt-1 border-t border-dashed">
|
||||
<span className="text-[9px] text-muted-foreground/80">{formatDate(file.created_at)}</span>
|
||||
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
@@ -87,6 +88,9 @@ func updateSystemConfig(ctx context.Context, key string, req UpdateSystemConfigR
|
||||
if err := tx.Model(&config).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repointUploadStorageDriversOnDriverSwitch(ctx, tx, key, originalDriver, req.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
resolveStorageMigrationTasksOnDirectDriverUpdate(ctx, tx, key, originalDriver, req.Value)
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -97,6 +101,38 @@ func updateSystemConfig(ctx context.Context, key string, req UpdateSystemConfigR
|
||||
return nil
|
||||
}
|
||||
|
||||
func repointUploadStorageDriversOnDriverSwitch(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
key string,
|
||||
originalDriver storage.Driver,
|
||||
newValue string,
|
||||
) error {
|
||||
if key != model.ConfigKeyStorageConfig || originalDriver == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var newCfg storage.Config
|
||||
if err := json.Unmarshal([]byte(newValue), &newCfg); err != nil {
|
||||
return fmt.Errorf("parse storage config for driver repoint: %w", err)
|
||||
}
|
||||
if newCfg.Driver == "" || newCfg.Driver == originalDriver {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := tx.Model(&model.Upload{}).
|
||||
Where("storage_driver = ? AND status != ?", string(originalDriver), model.UploadStatusDeleted).
|
||||
Update("storage_driver", string(newCfg.Driver))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
logger.InfoF(ctx, "[StorageConfig] switched driver %s -> %s, repointed %d upload records",
|
||||
originalDriver, newCfg.Driver, result.RowsAffected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveStorageMigrationTasksOnDirectDriverUpdate(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
|
||||
@@ -310,10 +310,6 @@ func validateAndMergeStorageConfig(ctx context.Context, value string, currentCon
|
||||
if err := validateMergedStorageConfig(ctx, currentCfg, newCfg, targetCfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if newCfg.Driver != "" && newCfg.Driver != currentCfg.Driver {
|
||||
// 切换存储类型时仅暂存目标后端参数,活动 driver 由迁移任务正式切换。
|
||||
targetCfg.Driver = currentCfg.Driver
|
||||
}
|
||||
|
||||
// 序列化为最终保存的真实明文配置,防止保存屏蔽的 ****** 字符
|
||||
unmaskedVal, err := json.Marshal(targetCfg)
|
||||
|
||||
@@ -441,7 +441,7 @@ func TestUpdateStorageConfigValidation(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stage local config while active s3 is unreachable", func(t *testing.T) {
|
||||
t.Run("switch to local while active s3 is unreachable", func(t *testing.T) {
|
||||
activeCfg := storage.DefaultConfig()
|
||||
activeCfg.Driver = storage.DriverS3
|
||||
activeCfg.S3.Endpoint = "http://127.0.0.1:9999"
|
||||
@@ -486,8 +486,8 @@ func TestUpdateStorageConfigValidation(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(dbCfg.Value), &savedCfg); err != nil {
|
||||
t.Fatalf("parse saved storage config failed: %v", err)
|
||||
}
|
||||
if savedCfg.Driver != storage.DriverS3 {
|
||||
t.Fatalf("active driver = %q, want %q until migration completes", savedCfg.Driver, storage.DriverS3)
|
||||
if savedCfg.Driver != storage.DriverLocal {
|
||||
t.Fatalf("active driver = %q, want %q after save", savedCfg.Driver, storage.DriverLocal)
|
||||
}
|
||||
if savedCfg.Local.Root != tempDir {
|
||||
t.Fatalf("staged local root = %q, want %q", savedCfg.Local.Root, tempDir)
|
||||
|
||||
@@ -168,10 +168,11 @@ func ForDriver(ctx context.Context, driver Driver) (Backend, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Driver == driver {
|
||||
return NewBackend(ctx, cfg, driver)
|
||||
backend, err := NewBackend(ctx, cfg, driver)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage configuration for driver %q is unavailable: %w", driver, err)
|
||||
}
|
||||
return nil, fmt.Errorf("storage configuration for driver %q is unavailable", driver)
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
type functionBackend struct {
|
||||
|
||||
Reference in New Issue
Block a user