You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

64 KiB

File Storage Module Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add a file storage module with configurable backends (local/OSS/MinIO), backend CRUD API, and frontend file management page with preview.

Architecture: Strategy Pattern for storage abstraction — a Storage interface with three implementations swappable via YAML config. File metadata stored in MySQL via GORM. Frontend uses existing shadcn/ui component patterns with drag-and-drop upload and inline preview (image/video/PDF).

Tech Stack: Go (go-zero, GORM), Aliyun OSS SDK, MinIO SDK, React 19, TypeScript, Tailwind CSS, lucide-react icons.


Task 1: Add Go dependencies (OSS + MinIO SDKs)

Files:

  • Modify: backend/go.mod
  • Modify: backend/go.sum

Step 1: Install dependencies

cd D:\APPS\base\backend
go get github.com/aliyun/aliyun-oss-go-sdk/oss@latest
go get github.com/minio/minio-go/v7@latest
go get github.com/google/uuid@latest

Step 2: Verify installation

Run: cd D:\APPS\base\backend && go mod tidy Expected: No errors. go.mod should list the three new dependencies.

Step 3: Commit

cd D:\APPS\base\backend
git add go.mod go.sum
git commit -m "chore: add aliyun-oss, minio, uuid dependencies"

Task 2: Config — add StorageConfig

Files:

  • Modify: backend/internal/config/config.go
  • Modify: backend/etc/base-api.yaml

Step 1: Add StorageConfig struct to config.go

Open backend/internal/config/config.go. The current content is:

package config

import "github.com/zeromicro/go-zero/rest"

type Config struct {
	rest.RestConf
	MySQL struct {
		DSN string
	}
	Casdoor struct {
		Endpoint     string
		ClientId     string
		ClientSecret string
		Organization string
		Application  string
		RedirectUrl  string
		FrontendUrl  string
	}
}

Add a Storage field and its sub-types. Replace the entire file with:

package config

import "github.com/zeromicro/go-zero/rest"

type Config struct {
	rest.RestConf
	MySQL struct {
		DSN string
	}
	Casdoor struct {
		Endpoint     string
		ClientId     string
		ClientSecret string
		Organization string
		Application  string
		RedirectUrl  string
		FrontendUrl  string
	}
	Storage StorageConfig
}

type StorageConfig struct {
	Type    string `json:",default=local"` // local, oss, minio
	MaxSize int64  `json:",default=104857600"` // 100MB
	Local   LocalStorageConfig
	OSS     OSSStorageConfig
	MinIO   MinIOStorageConfig
}

type LocalStorageConfig struct {
	RootDir string `json:",default=./uploads"`
}

type OSSStorageConfig struct {
	Endpoint        string
	AccessKeyId     string
	AccessKeySecret string
	Bucket          string
}

type MinIOStorageConfig struct {
	Endpoint        string
	AccessKeyId     string
	AccessKeySecret string
	Bucket          string
	UseSSL          bool
}

Step 2: Add Storage section to YAML config

Append to the end of backend/etc/base-api.yaml:


# 文件存储配置
Storage:
  Type: "local"
  MaxSize: 104857600
  Local:
    RootDir: "./uploads"
  OSS:
    Endpoint: ""
    AccessKeyId: ""
    AccessKeySecret: ""
    Bucket: ""
  MinIO:
    Endpoint: ""
    AccessKeyId: ""
    AccessKeySecret: ""
    Bucket: ""
    UseSSL: false

Step 3: Verify it compiles

Run: cd D:\APPS\base\backend && go build ./... Expected: Build succeeds.

Step 4: Commit

cd D:\APPS\base\backend
git add internal/config/config.go etc/base-api.yaml
git commit -m "feat: add storage config for local/oss/minio"

Task 3: Storage interface + Local implementation

Files:

  • Create: backend/internal/storage/storage.go
  • Create: backend/internal/storage/local.go

Step 1: Create the Storage interface and factory function

Create backend/internal/storage/storage.go:

package storage

import (
	"context"
	"fmt"
	"io"

	"github.com/youruser/base/internal/config"
)

// Storage defines the interface for file storage backends.
type Storage interface {
	// Upload stores a file with the given key.
	Upload(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error
	// Delete removes a file by key.
	Delete(ctx context.Context, key string) error
	// GetURL returns a URL for accessing the file.
	GetURL(ctx context.Context, key string) (string, error)
	// Exists checks whether a file exists.
	Exists(ctx context.Context, key string) (bool, error)
}

// NewStorage creates the appropriate storage backend from config.
func NewStorage(cfg config.StorageConfig) (Storage, error) {
	switch cfg.Type {
	case "local":
		return NewLocalStorage(cfg.Local)
	case "oss":
		return NewOSSStorage(cfg.OSS)
	case "minio":
		return NewMinIOStorage(cfg.MinIO)
	default:
		return nil, fmt.Errorf("unsupported storage type: %s", cfg.Type)
	}
}

Step 2: Create the Local storage implementation

Create backend/internal/storage/local.go:

package storage

import (
	"context"
	"fmt"
	"io"
	"os"
	"path/filepath"

	"github.com/youruser/base/internal/config"
)

type LocalStorage struct {
	rootDir string
}

func NewLocalStorage(cfg config.LocalStorageConfig) (*LocalStorage, error) {
	rootDir := cfg.RootDir
	if rootDir == "" {
		rootDir = "./uploads"
	}
	// Ensure root directory exists
	if err := os.MkdirAll(rootDir, 0755); err != nil {
		return nil, fmt.Errorf("failed to create upload dir: %w", err)
	}
	return &LocalStorage{rootDir: rootDir}, nil
}

func (s *LocalStorage) Upload(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error {
	fullPath := filepath.Join(s.rootDir, key)
	// Ensure parent directory exists
	if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
		return fmt.Errorf("failed to create directory: %w", err)
	}
	file, err := os.Create(fullPath)
	if err != nil {
		return fmt.Errorf("failed to create file: %w", err)
	}
	defer file.Close()
	if _, err := io.Copy(file, reader); err != nil {
		return fmt.Errorf("failed to write file: %w", err)
	}
	return nil
}

func (s *LocalStorage) Delete(ctx context.Context, key string) error {
	fullPath := filepath.Join(s.rootDir, key)
	if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("failed to delete file: %w", err)
	}
	return nil
}

func (s *LocalStorage) GetURL(ctx context.Context, key string) (string, error) {
	// For local storage, the URL is served via the backend proxy endpoint.
	// The actual URL construction happens in the logic layer using the file ID.
	// Here we return a relative path marker.
	return "local://" + key, nil
}

func (s *LocalStorage) Exists(ctx context.Context, key string) (bool, error) {
	fullPath := filepath.Join(s.rootDir, key)
	_, err := os.Stat(fullPath)
	if err == nil {
		return true, nil
	}
	if os.IsNotExist(err) {
		return false, nil
	}
	return false, err
}

// GetFilePath returns the full filesystem path for a key (used by download handler).
func (s *LocalStorage) GetFilePath(key string) string {
	return filepath.Join(s.rootDir, key)
}

Step 3: Verify it compiles

Run: cd D:\APPS\base\backend && go build ./internal/storage/... Expected: Build succeeds (OSS/MinIO stubs not yet needed because NewStorage factory references them but we haven't called it yet — this will fail). Let's create stubs instead.

Actually, the factory references NewOSSStorage and NewMinIOStorage, so we need at least stubs. Continue to Task 4 and Task 5 before verifying compile.

Step 4: Commit

cd D:\APPS\base\backend
git add internal/storage/storage.go internal/storage/local.go
git commit -m "feat: add Storage interface and local filesystem implementation"

Task 4: OSS storage implementation

Files:

  • Create: backend/internal/storage/oss.go

Step 1: Create the Aliyun OSS implementation

Create backend/internal/storage/oss.go:

package storage

import (
	"context"
	"fmt"
	"io"

	"github.com/aliyun/aliyun-oss-go-sdk/oss"
	"github.com/youruser/base/internal/config"
)

type OSSStorage struct {
	bucket *oss.Bucket
}

func NewOSSStorage(cfg config.OSSStorageConfig) (*OSSStorage, error) {
	if cfg.Endpoint == "" || cfg.AccessKeyId == "" || cfg.AccessKeySecret == "" || cfg.Bucket == "" {
		return nil, fmt.Errorf("OSS config incomplete: endpoint, accessKeyId, accessKeySecret, bucket are required")
	}
	client, err := oss.New(cfg.Endpoint, cfg.AccessKeyId, cfg.AccessKeySecret)
	if err != nil {
		return nil, fmt.Errorf("failed to create OSS client: %w", err)
	}
	bucket, err := client.Bucket(cfg.Bucket)
	if err != nil {
		return nil, fmt.Errorf("failed to get OSS bucket: %w", err)
	}
	return &OSSStorage{bucket: bucket}, nil
}

func (s *OSSStorage) Upload(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error {
	options := []oss.Option{
		oss.ContentType(contentType),
	}
	if err := s.bucket.PutObject(key, reader, options...); err != nil {
		return fmt.Errorf("failed to upload to OSS: %w", err)
	}
	return nil
}

func (s *OSSStorage) Delete(ctx context.Context, key string) error {
	if err := s.bucket.DeleteObject(key); err != nil {
		return fmt.Errorf("failed to delete from OSS: %w", err)
	}
	return nil
}

func (s *OSSStorage) GetURL(ctx context.Context, key string) (string, error) {
	// Generate a signed URL valid for 1 hour
	url, err := s.bucket.SignURL(key, oss.HTTPGet, 3600)
	if err != nil {
		return "", fmt.Errorf("failed to generate OSS signed URL: %w", err)
	}
	return url, nil
}

func (s *OSSStorage) Exists(ctx context.Context, key string) (bool, error) {
	exists, err := s.bucket.IsObjectExist(key)
	if err != nil {
		return false, fmt.Errorf("failed to check OSS object: %w", err)
	}
	return exists, nil
}

Step 2: Commit

cd D:\APPS\base\backend
git add internal/storage/oss.go
git commit -m "feat: add Aliyun OSS storage implementation"

Task 5: MinIO storage implementation

Files:

  • Create: backend/internal/storage/minio.go

Step 1: Create the MinIO implementation

Create backend/internal/storage/minio.go:

package storage

import (
	"context"
	"fmt"
	"io"
	"net/url"
	"time"

	"github.com/minio/minio-go/v7"
	"github.com/minio/minio-go/v7/pkg/credentials"
	"github.com/youruser/base/internal/config"
)

type MinIOStorage struct {
	client *minio.Client
	bucket string
}

func NewMinIOStorage(cfg config.MinIOStorageConfig) (*MinIOStorage, error) {
	if cfg.Endpoint == "" || cfg.AccessKeyId == "" || cfg.AccessKeySecret == "" || cfg.Bucket == "" {
		return nil, fmt.Errorf("MinIO config incomplete: endpoint, accessKeyId, accessKeySecret, bucket are required")
	}
	client, err := minio.New(cfg.Endpoint, &minio.Options{
		Creds:  credentials.NewStaticV4(cfg.AccessKeyId, cfg.AccessKeySecret, ""),
		Secure: cfg.UseSSL,
	})
	if err != nil {
		return nil, fmt.Errorf("failed to create MinIO client: %w", err)
	}

	// Ensure bucket exists
	ctx := context.Background()
	exists, err := client.BucketExists(ctx, cfg.Bucket)
	if err != nil {
		return nil, fmt.Errorf("failed to check MinIO bucket: %w", err)
	}
	if !exists {
		if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{}); err != nil {
			return nil, fmt.Errorf("failed to create MinIO bucket: %w", err)
		}
	}

	return &MinIOStorage{client: client, bucket: cfg.Bucket}, nil
}

func (s *MinIOStorage) Upload(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error {
	opts := minio.PutObjectOptions{
		ContentType: contentType,
	}
	if _, err := s.client.PutObject(ctx, s.bucket, key, reader, size, opts); err != nil {
		return fmt.Errorf("failed to upload to MinIO: %w", err)
	}
	return nil
}

func (s *MinIOStorage) Delete(ctx context.Context, key string) error {
	if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil {
		return fmt.Errorf("failed to delete from MinIO: %w", err)
	}
	return nil
}

func (s *MinIOStorage) GetURL(ctx context.Context, key string) (string, error) {
	// Generate a presigned URL valid for 1 hour
	reqParams := make(url.Values)
	presignedURL, err := s.client.PresignedGetObject(ctx, s.bucket, key, time.Hour, reqParams)
	if err != nil {
		return "", fmt.Errorf("failed to generate MinIO presigned URL: %w", err)
	}
	return presignedURL.String(), nil
}

func (s *MinIOStorage) Exists(ctx context.Context, key string) (bool, error) {
	_, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{})
	if err != nil {
		errResponse := minio.ToErrorResponse(err)
		if errResponse.Code == "NoSuchKey" {
			return false, nil
		}
		return false, fmt.Errorf("failed to check MinIO object: %w", err)
	}
	return true, nil
}

Step 2: Verify all storage code compiles

Run: cd D:\APPS\base\backend && go build ./internal/storage/... Expected: Build succeeds.

Step 3: Commit

cd D:\APPS\base\backend
git add internal/storage/minio.go
git commit -m "feat: add MinIO storage implementation"

Task 6: File data model (entity + model)

Files:

  • Create: backend/model/file_entity.go
  • Create: backend/model/file_model.go

Step 1: Create the File entity

Create backend/model/file_entity.go:

package model

import "time"

// File 文件模型
type File struct {
	Id          int64     `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
	Name        string    `gorm:"column:name;type:varchar(255);not null" json:"name"`
	Key         string    `gorm:"column:key;type:varchar(500);uniqueIndex" json:"key"`
	Size        int64     `gorm:"column:size;not null" json:"size"`
	MimeType    string    `gorm:"column:mime_type;type:varchar(100)" json:"mimeType"`
	Category    string    `gorm:"column:category;type:varchar(50);index;default:'default'" json:"category"`
	IsPublic    bool      `gorm:"column:is_public;default:false" json:"isPublic"`
	UserId      int64     `gorm:"column:user_id;index" json:"userId"`
	StorageType string    `gorm:"column:storage_type;type:varchar(20)" json:"storageType"`
	Status      int       `gorm:"column:status;default:1" json:"status"`
	CreatedAt   time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"`
	UpdatedAt   time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"`
}

// TableName 指定表名
func (File) TableName() string {
	return "file"
}

Step 2: Create the File model (data access methods)

Create backend/model/file_model.go:

package model

import (
	"context"
	"errors"

	"gorm.io/gorm"
)

// FileInsert 插入文件记录
func FileInsert(ctx context.Context, db *gorm.DB, file *File) (int64, error) {
	result := db.WithContext(ctx).Create(file)
	if result.Error != nil {
		return 0, result.Error
	}
	return file.Id, nil
}

// FileFindOne 根据 ID 查询文件
func FileFindOne(ctx context.Context, db *gorm.DB, id int64) (*File, error) {
	var file File
	result := db.WithContext(ctx).Where("status = 1").First(&file, id)
	if result.Error != nil {
		if errors.Is(result.Error, gorm.ErrRecordNotFound) {
			return nil, ErrNotFound
		}
		return nil, result.Error
	}
	return &file, nil
}

// FileFindList 查询文件列表(分页+筛选)
// userRole: 调用者角色,admin/super_admin 可看全部,其他只能看自己的+公开的
func FileFindList(ctx context.Context, db *gorm.DB, page, pageSize int64, keyword, category, mimeType string, userId int64, userRole string) ([]File, int64, error) {
	var files []File
	var total int64

	query := db.WithContext(ctx).Model(&File{}).Where("status = 1")

	// 权限过滤:非 admin/super_admin 只能看自己的文件 + 公开文件
	if userRole != RoleAdmin && userRole != RoleSuperAdmin {
		query = query.Where("user_id = ? OR is_public = ?", userId, true)
	}

	// 关键字搜索(文件名)
	if keyword != "" {
		query = query.Where("name LIKE ?", "%"+keyword+"%")
	}

	// 分类筛选
	if category != "" {
		query = query.Where("category = ?", category)
	}

	// MIME 类型筛选(前缀匹配,如 "image" 匹配 "image/png")
	if mimeType != "" {
		query = query.Where("mime_type LIKE ?", mimeType+"%")
	}

	// 统计总数
	if err := query.Count(&total).Error; err != nil {
		return nil, 0, err
	}

	// 分页查询,按创建时间倒序
	offset := (page - 1) * pageSize
	if offset < 0 {
		offset = 0
	}
	err := query.Order("created_at DESC").Offset(int(offset)).Limit(int(pageSize)).Find(&files).Error
	if err != nil {
		return nil, 0, err
	}

	return files, total, nil
}

// FileUpdate 更新文件记录
func FileUpdate(ctx context.Context, db *gorm.DB, file *File) error {
	result := db.WithContext(ctx).Save(file)
	return result.Error
}

// FileDelete 软删除文件(设置 status=0)
func FileDelete(ctx context.Context, db *gorm.DB, id int64) error {
	result := db.WithContext(ctx).Model(&File{}).Where("id = ?", id).Update("status", 0)
	return result.Error
}

Step 3: Verify it compiles

Run: cd D:\APPS\base\backend && go build ./model/... Expected: Build succeeds.

Step 4: Commit

cd D:\APPS\base\backend
git add model/file_entity.go model/file_model.go
git commit -m "feat: add File entity and data access model"

Task 7: API definition + goctl code generation

Files:

  • Create: backend/api/file.api
  • Modify: backend/base.api (add import)
  • Regenerate: backend/internal/types/types.go
  • Regenerate: backend/internal/handler/routes.go

Step 1: Create file.api

Create backend/api/file.api:

syntax = "v1"

// ========== 文件管理类型定义 ==========
type (
	// 文件信息
	FileInfo {
		Id          int64  `json:"id"`
		Name        string `json:"name"`
		Key         string `json:"key"`
		Size        int64  `json:"size"`
		MimeType    string `json:"mimeType"`
		Category    string `json:"category"`
		IsPublic    bool   `json:"isPublic"`
		UserId      int64  `json:"userId"`
		StorageType string `json:"storageType"`
		Url         string `json:"url"`
		CreatedAt   string `json:"createdAt"`
		UpdatedAt   string `json:"updatedAt"`
	}

	// 文件列表请求
	FileListRequest {
		Page     int    `form:"page,default=1"`
		PageSize int    `form:"pageSize,default=20"`
		Keyword  string `form:"keyword,optional"`
		Category string `form:"category,optional"`
		MimeType string `form:"mimeType,optional"`
	}

	// 文件列表响应
	FileListResponse {
		Total int64      `json:"total"`
		List  []FileInfo `json:"list"`
	}

	// 获取文件请求
	GetFileRequest {
		Id int64 `path:"id"`
	}

	// 更新文件请求
	UpdateFileRequest {
		Id       int64  `path:"id"`
		Name     string `json:"name,optional"`
		Category string `json:"category,optional"`
		IsPublic *bool  `json:"isPublic,optional"`
	}

	// 删除文件请求
	DeleteFileRequest {
		Id int64 `path:"id"`
	}

	// 文件 URL 响应
	FileUrlResponse {
		Url string `json:"url"`
	}
)

Step 2: Add import to base.api

In backend/base.api, add after import "api/dashboard.api":

import "api/file.api"

And add the file server block at the end of base.api (before the final empty line), after the dashboard block:

@server (
	prefix:     /api/v1
	group:      file
	middleware: Cors,Log,Auth,Authz
)
service base-api {
	@doc "上传文件"
	@handler uploadFile
	post /file/upload returns (FileInfo)

	@doc "获取文件列表"
	@handler getFileList
	get /files (FileListRequest) returns (FileListResponse)

	@doc "获取文件详情"
	@handler getFile
	get /file/:id (GetFileRequest) returns (FileInfo)

	@doc "获取文件访问URL"
	@handler getFileUrl
	get /file/:id/url (GetFileRequest) returns (FileUrlResponse)

	@doc "更新文件信息"
	@handler updateFile
	put /file/:id (UpdateFileRequest) returns (FileInfo)

	@doc "删除文件"
	@handler deleteFile
	delete /file/:id (DeleteFileRequest) returns (Response)
}

Step 3: Run goctl code generation

Run: cd D:\APPS\base\backend && goctl api go -api base.api -dir . Expected: Generates/updates:

  • internal/types/types.go — new file types added
  • internal/handler/routes.go — new file routes added
  • internal/handler/file/ — 6 new handler stubs created
  • internal/logic/file/ — 6 new logic stubs created

Step 4: Verify it compiles

Run: cd D:\APPS\base\backend && go build ./... Expected: Build succeeds (logic stubs return nil).

Step 5: Commit

cd D:\APPS\base\backend
git add api/file.api base.api internal/types/types.go internal/handler/ internal/logic/file/
git commit -m "feat: add file API definition and generate handlers/logic stubs"

Task 8: ServiceContext — integrate Storage + File migration + Casbin policies

Files:

  • Modify: backend/internal/svc/servicecontext.go

Step 1: Add Storage to ServiceContext and update initialization

In servicecontext.go, make these changes:

  1. Add import for "github.com/youruser/base/internal/storage"
  2. Add Storage storage.Storage field to ServiceContext struct
  3. Add &model.File{} to AutoMigrate call
  4. Initialize storage with storage.NewStorage(c.Storage)
  5. Add file policies to seedCasbinPolicies

The ServiceContext struct becomes:

type ServiceContext struct {
	Config   config.Config
	Cors     rest.Middleware
	Log      rest.Middleware
	Auth     rest.Middleware
	Authz    rest.Middleware
	DB       *gorm.DB
	Enforcer *casbin.Enforcer
	Storage  storage.Storage
}

In NewServiceContext, after the DB connection and before Casbin init, add:

	// 自动迁移表
	err = db.AutoMigrate(&model.User{}, &model.Profile{}, &model.File{})

After seedCasbinPolicies(enforcer), add storage initialization:

	// 初始化存储
	store, err := storage.NewStorage(c.Storage)
	if err != nil {
		panic("Failed to initialize storage: " + err.Error())
	}
	log.Printf("[Storage] Initialized with type: %s", c.Storage.Type)

And set Storage: store in the return struct.

Add file policies to seedCasbinPolicies:

		// user: 文件管理
		{"user", "/api/v1/file/upload", "POST"},
		{"user", "/api/v1/files", "GET"},
		{"user", "/api/v1/file/:id", "GET"},
		{"user", "/api/v1/file/:id/url", "GET"},
		{"user", "/api/v1/file/:id", "PUT"},

		// super_admin: 文件删除
		{"super_admin", "/api/v1/file/:id", "DELETE"},

Step 2: Verify it compiles

Run: cd D:\APPS\base\backend && go build ./... Expected: Build succeeds.

Step 3: Commit

cd D:\APPS\base\backend
git add internal/svc/servicecontext.go
git commit -m "feat: integrate Storage into ServiceContext, add File migration and Casbin policies"

Task 9: Upload file logic (custom handler — multipart)

Files:

  • Modify: backend/internal/handler/file/uploadfilehandler.go
  • Modify: backend/internal/logic/file/uploadfilelogic.go

Step 1: Rewrite the upload handler for multipart form

The goctl-generated handler uses httpx.Parse for JSON, but upload needs multipart/form-data. Replace the generated uploadfilehandler.go with:

package file

import (
	"net/http"

	"github.com/youruser/base/internal/logic/file"
	"github.com/youruser/base/internal/svc"
	"github.com/zeromicro/go-zero/rest/httpx"
)

func UploadFileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// Parse multipart form with max size from config
		maxSize := svcCtx.Config.Storage.MaxSize
		if maxSize <= 0 {
			maxSize = 100 << 20 // 100MB default
		}
		r.Body = http.MaxBytesReader(w, r.Body, maxSize)
		if err := r.ParseMultipartForm(maxSize); err != nil {
			httpx.ErrorCtx(r.Context(), w, err)
			return
		}

		l := file.NewUploadFileLogic(r.Context(), svcCtx)
		resp, err := l.UploadFile(r)
		if err != nil {
			httpx.ErrorCtx(r.Context(), w, err)
		} else {
			httpx.OkJsonCtx(r.Context(), w, resp)
		}
	}
}

Step 2: Implement the upload logic

Replace the generated uploadfilelogic.go with:

package file

import (
	"context"
	"fmt"
	"net/http"
	"path/filepath"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/youruser/base/internal/svc"
	"github.com/youruser/base/internal/types"
	"github.com/youruser/base/model"

	"github.com/zeromicro/go-zero/core/logx"
)

type UploadFileLogic struct {
	logx.Logger
	ctx    context.Context
	svcCtx *svc.ServiceContext
}

func NewUploadFileLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UploadFileLogic {
	return &UploadFileLogic{
		Logger: logx.WithContext(ctx),
		ctx:    ctx,
		svcCtx: svcCtx,
	}
}

func (l *UploadFileLogic) UploadFile(r *http.Request) (resp *types.FileInfo, err error) {
	// Get user info from context
	userId, _ := l.ctx.Value("userId").(int64)
	if userId == 0 {
		// Try json.Number format
		if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok {
			userId, _ = num.Int64()
		}
	}

	// Get the uploaded file
	file, header, err := r.FormFile("file")
	if err != nil {
		return nil, fmt.Errorf("获取上传文件失败: %w", err)
	}
	defer file.Close()

	// Get optional form fields
	category := r.FormValue("category")
	if category == "" {
		category = "default"
	}
	isPublicStr := r.FormValue("isPublic")
	isPublic := isPublicStr == "true" || isPublicStr == "1"

	// Generate storage key: {category}/{YYYY-MM}/{uuid}{ext}
	ext := strings.ToLower(filepath.Ext(header.Filename))
	now := time.Now()
	key := fmt.Sprintf("%s/%s/%s%s", category, now.Format("2006-01"), uuid.New().String(), ext)

	// Detect MIME type
	contentType := header.Header.Get("Content-Type")
	if contentType == "" {
		contentType = "application/octet-stream"
	}

	// Upload to storage backend
	if err := l.svcCtx.Storage.Upload(l.ctx, key, file, header.Size, contentType); err != nil {
		return nil, fmt.Errorf("上传文件失败: %w", err)
	}

	// Save file record to database
	fileRecord := &model.File{
		Name:        header.Filename,
		Key:         key,
		Size:        header.Size,
		MimeType:    contentType,
		Category:    category,
		IsPublic:    isPublic,
		UserId:      userId,
		StorageType: l.svcCtx.Config.Storage.Type,
		Status:      1,
	}

	id, err := model.FileInsert(l.ctx, l.svcCtx.DB, fileRecord)
	if err != nil {
		// Try to clean up the uploaded file
		l.svcCtx.Storage.Delete(l.ctx, key)
		return nil, fmt.Errorf("保存文件记录失败: %w", err)
	}

	// Get URL
	url, _ := l.svcCtx.Storage.GetURL(l.ctx, key)
	// For local storage, use the API endpoint
	if l.svcCtx.Config.Storage.Type == "local" {
		url = fmt.Sprintf("/api/v1/file/%d/url", id)
	}

	l.Infof("文件上传成功,fileId=%d, name=%s, size=%d", id, header.Filename, header.Size)

	return &types.FileInfo{
		Id:          id,
		Name:        header.Filename,
		Key:         key,
		Size:        header.Size,
		MimeType:    contentType,
		Category:    category,
		IsPublic:    isPublic,
		UserId:      userId,
		StorageType: l.svcCtx.Config.Storage.Type,
		Url:         url,
		CreatedAt:   fileRecord.CreatedAt.Format("2006-01-02 15:04:05"),
		UpdatedAt:   fileRecord.UpdatedAt.Format("2006-01-02 15:04:05"),
	}, nil
}

Step 3: Verify it compiles

Run: cd D:\APPS\base\backend && go build ./... Expected: Build succeeds.

Step 4: Commit

cd D:\APPS\base\backend
git add internal/handler/file/uploadfilehandler.go internal/logic/file/uploadfilelogic.go
git commit -m "feat: implement file upload handler and logic (multipart)"

Task 10: Implement remaining file logic (list, get, getUrl, update, delete)

Files:

  • Modify: backend/internal/logic/file/getfilelistlogic.go
  • Modify: backend/internal/logic/file/getfilelogic.go
  • Modify: backend/internal/logic/file/getfileurllogic.go
  • Modify: backend/internal/logic/file/updatefilelogic.go
  • Modify: backend/internal/logic/file/deletefilelogic.go

Step 1: Implement GetFileList logic

Replace the body of GetFileList method in getfilelistlogic.go:

func (l *GetFileListLogic) GetFileList(req *types.FileListRequest) (resp *types.FileListResponse, err error) {
	userId, _ := l.ctx.Value("userId").(int64)
	if userId == 0 {
		if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok {
			userId, _ = num.Int64()
		}
	}
	userRole, _ := l.ctx.Value("role").(string)

	files, total, err := model.FileFindList(l.ctx, l.svcCtx.DB,
		int64(req.Page), int64(req.PageSize),
		req.Keyword, req.Category, req.MimeType,
		userId, userRole)
	if err != nil {
		return nil, fmt.Errorf("查询文件列表失败: %v", err)
	}

	list := make([]types.FileInfo, 0, len(files))
	for _, f := range files {
		url, _ := l.svcCtx.Storage.GetURL(l.ctx, f.Key)
		if f.StorageType == "local" {
			url = fmt.Sprintf("/api/v1/file/%d/url", f.Id)
		}
		list = append(list, types.FileInfo{
			Id:          f.Id,
			Name:        f.Name,
			Key:         f.Key,
			Size:        f.Size,
			MimeType:    f.MimeType,
			Category:    f.Category,
			IsPublic:    f.IsPublic,
			UserId:      f.UserId,
			StorageType: f.StorageType,
			Url:         url,
			CreatedAt:   f.CreatedAt.Format("2006-01-02 15:04:05"),
			UpdatedAt:   f.UpdatedAt.Format("2006-01-02 15:04:05"),
		})
	}

	return &types.FileListResponse{
		Total: total,
		List:  list,
	}, nil
}

Imports needed: "fmt", "github.com/youruser/base/model".

Step 2: Implement GetFile logic

Replace the body of GetFile method in getfilelogic.go:

func (l *GetFileLogic) GetFile(req *types.GetFileRequest) (resp *types.FileInfo, err error) {
	f, err := model.FileFindOne(l.ctx, l.svcCtx.DB, req.Id)
	if err != nil {
		if err == model.ErrNotFound {
			return nil, fmt.Errorf("文件不存在")
		}
		return nil, fmt.Errorf("查询文件失败: %v", err)
	}

	// Permission check: non-admin can only see own files + public files
	userId, _ := l.ctx.Value("userId").(int64)
	if userId == 0 {
		if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok {
			userId, _ = num.Int64()
		}
	}
	userRole, _ := l.ctx.Value("role").(string)
	if userRole != model.RoleAdmin && userRole != model.RoleSuperAdmin {
		if f.UserId != userId && !f.IsPublic {
			return nil, fmt.Errorf("无权访问该文件")
		}
	}

	url, _ := l.svcCtx.Storage.GetURL(l.ctx, f.Key)
	if f.StorageType == "local" {
		url = fmt.Sprintf("/api/v1/file/%d/url", f.Id)
	}

	return &types.FileInfo{
		Id:          f.Id,
		Name:        f.Name,
		Key:         f.Key,
		Size:        f.Size,
		MimeType:    f.MimeType,
		Category:    f.Category,
		IsPublic:    f.IsPublic,
		UserId:      f.UserId,
		StorageType: f.StorageType,
		Url:         url,
		CreatedAt:   f.CreatedAt.Format("2006-01-02 15:04:05"),
		UpdatedAt:   f.UpdatedAt.Format("2006-01-02 15:04:05"),
	}, nil
}

Step 3: Implement GetFileUrl logic

Replace the body of GetFileUrl method in getfileurllogic.go:

func (l *GetFileUrlLogic) GetFileUrl(req *types.GetFileRequest) (resp *types.FileUrlResponse, err error) {
	f, err := model.FileFindOne(l.ctx, l.svcCtx.DB, req.Id)
	if err != nil {
		if err == model.ErrNotFound {
			return nil, fmt.Errorf("文件不存在")
		}
		return nil, fmt.Errorf("查询文件失败: %v", err)
	}

	// Permission check
	userId, _ := l.ctx.Value("userId").(int64)
	if userId == 0 {
		if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok {
			userId, _ = num.Int64()
		}
	}
	userRole, _ := l.ctx.Value("role").(string)
	if userRole != model.RoleAdmin && userRole != model.RoleSuperAdmin {
		if f.UserId != userId && !f.IsPublic {
			return nil, fmt.Errorf("无权访问该文件")
		}
	}

	url, err := l.svcCtx.Storage.GetURL(l.ctx, f.Key)
	if err != nil {
		return nil, fmt.Errorf("获取文件URL失败: %v", err)
	}

	// For local storage, serve the file directly via redirect or inline
	if f.StorageType == "local" {
		// Return a direct download URL pattern; the handler will serve the file
		url = "local://" + f.Key
	}

	return &types.FileUrlResponse{
		Url: url,
	}, nil
}

Note: For local storage, the handler layer (getfileurlhandler.go) should be modified to serve the file directly instead of returning JSON. We'll handle this in the next step.

Step 4: Implement UpdateFile logic

Replace the body of UpdateFile method in updatefilelogic.go:

func (l *UpdateFileLogic) UpdateFile(req *types.UpdateFileRequest) (resp *types.FileInfo, err error) {
	f, err := model.FileFindOne(l.ctx, l.svcCtx.DB, req.Id)
	if err != nil {
		if err == model.ErrNotFound {
			return nil, fmt.Errorf("文件不存在")
		}
		return nil, fmt.Errorf("查询文件失败: %v", err)
	}

	// Permission check: user can only edit own files
	userId, _ := l.ctx.Value("userId").(int64)
	if userId == 0 {
		if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok {
			userId, _ = num.Int64()
		}
	}
	userRole, _ := l.ctx.Value("role").(string)
	if userRole != model.RoleAdmin && userRole != model.RoleSuperAdmin {
		if f.UserId != userId {
			return nil, fmt.Errorf("无权编辑该文件")
		}
	}

	// Apply updates
	if req.Name != "" {
		f.Name = req.Name
	}
	if req.Category != "" {
		f.Category = req.Category
	}
	if req.IsPublic != nil {
		f.IsPublic = *req.IsPublic
	}

	if err := model.FileUpdate(l.ctx, l.svcCtx.DB, f); err != nil {
		return nil, fmt.Errorf("更新文件失败: %v", err)
	}

	url, _ := l.svcCtx.Storage.GetURL(l.ctx, f.Key)
	if f.StorageType == "local" {
		url = fmt.Sprintf("/api/v1/file/%d/url", f.Id)
	}

	return &types.FileInfo{
		Id:          f.Id,
		Name:        f.Name,
		Key:         f.Key,
		Size:        f.Size,
		MimeType:    f.MimeType,
		Category:    f.Category,
		IsPublic:    f.IsPublic,
		UserId:      f.UserId,
		StorageType: f.StorageType,
		Url:         url,
		CreatedAt:   f.CreatedAt.Format("2006-01-02 15:04:05"),
		UpdatedAt:   f.UpdatedAt.Format("2006-01-02 15:04:05"),
	}, nil
}

Step 5: Implement DeleteFile logic

Replace the body of DeleteFile method in deletefilelogic.go:

func (l *DeleteFileLogic) DeleteFile(req *types.DeleteFileRequest) (resp *types.Response, err error) {
	f, err := model.FileFindOne(l.ctx, l.svcCtx.DB, req.Id)
	if err != nil {
		if err == model.ErrNotFound {
			return nil, fmt.Errorf("文件不存在")
		}
		return nil, fmt.Errorf("查询文件失败: %v", err)
	}

	// Soft delete from database
	if err := model.FileDelete(l.ctx, l.svcCtx.DB, req.Id); err != nil {
		return nil, fmt.Errorf("删除文件失败: %v", err)
	}

	// Delete from storage backend
	if delErr := l.svcCtx.Storage.Delete(l.ctx, f.Key); delErr != nil {
		l.Errorf("删除存储文件失败: %v (file id=%d, key=%s)", delErr, f.Id, f.Key)
		// Don't return error — DB record is already soft-deleted
	}

	l.Infof("文件删除成功,fileId=%d, name=%s", f.Id, f.Name)

	return &types.Response{
		Code:    200,
		Message: "删除成功",
		Success: true,
	}, nil
}

Step 6: Modify GetFileUrl handler to serve local files

Replace getfileurlhandler.go to handle local file serving:

package file

import (
	"net/http"
	"strings"

	"github.com/youruser/base/internal/logic/file"
	"github.com/youruser/base/internal/storage"
	"github.com/youruser/base/internal/svc"
	"github.com/youruser/base/internal/types"
	"github.com/zeromicro/go-zero/rest/httpx"
)

func GetFileUrlHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req types.GetFileRequest
		if err := httpx.Parse(r, &req); err != nil {
			httpx.ErrorCtx(r.Context(), w, err)
			return
		}

		l := file.NewGetFileUrlLogic(r.Context(), svcCtx)
		resp, err := l.GetFileUrl(&req)
		if err != nil {
			httpx.ErrorCtx(r.Context(), w, err)
			return
		}

		// For local storage, serve the file directly
		if strings.HasPrefix(resp.Url, "local://") {
			key := strings.TrimPrefix(resp.Url, "local://")
			if localStorage, ok := svcCtx.Storage.(*storage.LocalStorage); ok {
				filePath := localStorage.GetFilePath(key)
				http.ServeFile(w, r, filePath)
				return
			}
		}

		// For OSS/MinIO, return the signed URL as JSON
		httpx.OkJsonCtx(r.Context(), w, resp)
	}
}

Step 7: Verify it compiles

Run: cd D:\APPS\base\backend && go build ./... Expected: Build succeeds.

Step 8: Commit

cd D:\APPS\base\backend
git add internal/logic/file/ internal/handler/file/
git commit -m "feat: implement file CRUD logic (list, get, getUrl, update, delete)"

Task 11: Backend integration test with curl

Prerequisites: Backend running via air on port 8888.

Step 1: Start backend if not running

Run: cd D:\APPS\base\backend && air

Step 2: Login as admin to get token

curl -s -X POST http://localhost:8888/api/v1/login -H "Content-Type: application/json" -d '{"account":"admin","password":"admin123"}' | jq .token -r

Save the token as TOKEN.

Step 3: Upload a test file

curl -s -X POST http://localhost:8888/api/v1/file/upload -H "Authorization: Bearer $TOKEN" -F "file=@D:\APPS\base\backend\etc\base-api.yaml" -F "category=test" -F "isPublic=true" | jq .

Expected: Returns FileInfo with id, name, key, mimeType, etc.

Step 4: List files

curl -s "http://localhost:8888/api/v1/files?page=1&pageSize=10" -H "Authorization: Bearer $TOKEN" | jq .

Expected: Returns list with the uploaded file.

Step 5: Get file detail

curl -s "http://localhost:8888/api/v1/file/1" -H "Authorization: Bearer $TOKEN" | jq .

Step 6: Get file URL

curl -s "http://localhost:8888/api/v1/file/1/url" -H "Authorization: Bearer $TOKEN"

Expected: For local storage, serves the file content directly.

Step 7: Update file

curl -s -X PUT "http://localhost:8888/api/v1/file/1" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"name":"renamed.yaml","category":"docs"}' | jq .

Step 8: Delete file

curl -s -X DELETE "http://localhost:8888/api/v1/file/1" -H "Authorization: Bearer $TOKEN" | jq .

Expected: Returns {"code":200,"message":"删除成功","success":true}.


Task 12: Frontend types — add File types

Files:

  • Modify: frontend/react-shadcn/pc/src/types/index.ts

Step 1: Add file-related types

Append to the end of frontend/react-shadcn/pc/src/types/index.ts:


// File Types
export interface FileInfo {
  id: number
  name: string
  key: string
  size: number
  mimeType: string
  category: string
  isPublic: boolean
  userId: number
  storageType: string
  url: string
  createdAt: string
  updatedAt: string
}

export interface FileListRequest {
  page?: number
  pageSize?: number
  keyword?: string
  category?: string
  mimeType?: string
}

export interface FileListResponse {
  code: number
  message: string
  success: boolean
  data: {
    list: FileInfo[]
    total: number
  }
}

export interface UpdateFileRequest {
  name?: string
  category?: string
  isPublic?: boolean
}

Step 2: Commit

cd D:\APPS\base\frontend\react-shadcn\pc
git add src/types/index.ts
git commit -m "feat: add FileInfo and file-related types"

Task 13: Frontend API client — add file methods

Files:

  • Modify: frontend/react-shadcn/pc/src/services/api.ts

Step 1: Add import for new types

In api.ts, update the import to include the new types:

import type {
  // ... existing imports ...
  FileInfo,
  FileListRequest,
  FileListResponse,
  UpdateFileRequest,
} from '@/types'

Step 2: Add file API methods to ApiClient class

Add these methods inside the ApiClient class, before the healthCheck method:

  // File Management
  async uploadFile(file: File, category?: string, isPublic?: boolean): Promise<ApiResponse<FileInfo>> {
    const url = `${API_BASE_URL}/file/upload`
    const formData = new FormData()
    formData.append('file', file)
    if (category) formData.append('category', category)
    if (isPublic !== undefined) formData.append('isPublic', isPublic ? 'true' : 'false')

    const headers: Record<string, string> = {}
    if (this.token) {
      headers['Authorization'] = `Bearer ${this.token}`
    }
    // Note: Do NOT set Content-Type header — browser sets it with boundary for FormData

    const response = await fetch(url, {
      method: 'POST',
      headers,
      body: formData,
    })

    const data = await response.json()
    if (!response.ok) {
      throw new Error(data.message || 'Upload failed')
    }

    // Wrap raw response into standard format if needed
    if ('id' in data) {
      return { code: 200, message: 'success', success: true, data }
    }
    return data
  }

  async getFiles(params: FileListRequest = {}): Promise<FileListResponse> {
    const queryParams = new URLSearchParams()
    if (params.page) queryParams.append('page', params.page.toString())
    if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString())
    if (params.keyword) queryParams.append('keyword', params.keyword)
    if (params.category) queryParams.append('category', params.category)
    if (params.mimeType) queryParams.append('mimeType', params.mimeType)

    const rawData = await this.request<{ total: number; list: FileInfo[] }>(`/files?${queryParams}`)

    if ('success' in rawData) {
      return rawData as FileListResponse
    }
    return {
      code: 200,
      message: 'success',
      success: true,
      data: { list: rawData.list || [], total: rawData.total || 0 },
    }
  }

  async getFile(id: number): Promise<ApiResponse<FileInfo>> {
    const rawData = await this.request<FileInfo>(`/file/${id}`)
    if ('success' in rawData) return rawData as ApiResponse<FileInfo>
    return { code: 200, message: 'success', success: true, data: rawData }
  }

  async getFileUrl(id: number): Promise<string> {
    // For local storage, the URL endpoint serves the file directly
    // For OSS/MinIO, it returns a JSON with the signed URL
    const url = `${API_BASE_URL}/file/${id}/url`
    const headers: Record<string, string> = {}
    if (this.token) {
      headers['Authorization'] = `Bearer ${this.token}`
    }
    const response = await fetch(url, { headers })
    const contentType = response.headers.get('Content-Type') || ''

    if (contentType.includes('application/json')) {
      const data = await response.json()
      return data.url
    }
    // For local storage, the endpoint serves the file — return the URL itself
    return url
  }

  async updateFile(id: number, data: UpdateFileRequest): Promise<ApiResponse<FileInfo>> {
    const rawData = await this.request<FileInfo>(`/file/${id}`, {
      method: 'PUT',
      body: JSON.stringify(data),
    })
    if ('success' in rawData) return rawData as ApiResponse<FileInfo>
    return { code: 200, message: 'success', success: true, data: rawData }
  }

  async deleteFile(id: number): Promise<ApiResponse<void>> {
    return this.request<ApiResponse<void>>(`/file/${id}`, {
      method: 'DELETE',
    })
  }

Step 3: Commit

cd D:\APPS\base\frontend\react-shadcn\pc
git add src/services/api.ts
git commit -m "feat: add file CRUD methods to API client"

Task 14: Frontend — File Management Page

Files:

  • Create: frontend/react-shadcn/pc/src/pages/FileManagementPage.tsx

Step 1: Create the file management page

Create frontend/react-shadcn/pc/src/pages/FileManagementPage.tsx. This page should follow the same patterns as UserManagementPage.tsx:

import { useState, useEffect, useRef, useCallback } from 'react'
import { Upload, Search, Eye, Edit2, Trash2, Download, X, File as FileIcon, Image, Film, FileText } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'
import { Modal } from '@/components/ui/Modal'
import {
  Table,
  TableHeader,
  TableBody,
  TableRow,
  TableHead,
  TableCell,
} from '@/components/ui/Table'
import type { FileInfo, UpdateFileRequest } from '@/types'
import { apiClient } from '@/services/api'
import { useAuth } from '@/contexts/AuthContext'

const CATEGORY_OPTIONS = [
  { value: '', label: '全部分类' },
  { value: 'default', label: '默认' },
  { value: 'avatar', label: '头像' },
  { value: 'document', label: '文档' },
  { value: 'media', label: '媒体' },
]

const MIME_FILTER_OPTIONS = [
  { value: '', label: '全部类型' },
  { value: 'image', label: '图片' },
  { value: 'video', label: '视频' },
  { value: 'application/pdf', label: 'PDF' },
]

function formatFileSize(bytes: number): string {
  if (bytes === 0) return '0 B'
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}

function getFileIcon(mimeType: string) {
  if (mimeType.startsWith('image/')) return <Image className="h-4 w-4 text-green-400" />
  if (mimeType.startsWith('video/')) return <Film className="h-4 w-4 text-purple-400" />
  if (mimeType === 'application/pdf') return <FileText className="h-4 w-4 text-red-400" />
  return <FileIcon className="h-4 w-4 text-gray-400" />
}

export function FileManagementPage() {
  const { user: currentUser } = useAuth()
  const [files, setFiles] = useState<FileInfo[]>([])
  const [total, setTotal] = useState(0)
  const [isLoading, setIsLoading] = useState(true)
  const [searchQuery, setSearchQuery] = useState('')
  const [categoryFilter, setCategoryFilter] = useState('')
  const [mimeFilter, setMimeFilter] = useState('')
  const [page, setPage] = useState(1)
  const [error, setError] = useState<string | null>(null)

  // Upload state
  const [isUploading, setIsUploading] = useState(false)
  const [isDragging, setIsDragging] = useState(false)
  const [uploadCategory, setUploadCategory] = useState('default')
  const [uploadIsPublic, setUploadIsPublic] = useState(false)
  const fileInputRef = useRef<HTMLInputElement>(null)

  // Edit state
  const [editModalOpen, setEditModalOpen] = useState(false)
  const [editingFile, setEditingFile] = useState<FileInfo | null>(null)
  const [editForm, setEditForm] = useState<UpdateFileRequest>({})

  // Preview state
  const [previewFile, setPreviewFile] = useState<FileInfo | null>(null)
  const [previewUrl, setPreviewUrl] = useState<string | null>(null)

  // Delete state
  const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
  const [fileToDelete, setFileToDelete] = useState<FileInfo | null>(null)
  const [isDeleting, setIsDeleting] = useState(false)

  const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'super_admin'
  const isSuperAdmin = currentUser?.role === 'super_admin'

  const fetchFiles = useCallback(async () => {
    try {
      setIsLoading(true)
      setError(null)
      const response = await apiClient.getFiles({
        page,
        pageSize: 20,
        keyword: searchQuery,
        category: categoryFilter,
        mimeType: mimeFilter,
      })
      if (response.success && response.data) {
        setFiles(response.data.list)
        setTotal(response.data.total)
      } else {
        setFiles([])
        setTotal(0)
      }
    } catch (err) {
      console.error('Failed to fetch files:', err)
      setError('获取文件列表失败')
      setFiles([])
    } finally {
      setIsLoading(false)
    }
  }, [page, searchQuery, categoryFilter, mimeFilter])

  useEffect(() => {
    fetchFiles()
  }, [fetchFiles])

  // Upload handlers
  const handleUpload = async (fileList: FileList | null) => {
    if (!fileList || fileList.length === 0) return
    setIsUploading(true)
    try {
      for (const file of Array.from(fileList)) {
        await apiClient.uploadFile(file, uploadCategory, uploadIsPublic)
      }
      await fetchFiles()
    } catch (err) {
      console.error('Upload failed:', err)
      alert('上传失败')
    } finally {
      setIsUploading(false)
      if (fileInputRef.current) fileInputRef.current.value = ''
    }
  }

  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault()
    setIsDragging(false)
    handleUpload(e.dataTransfer.files)
  }

  // Preview handler
  const handlePreview = async (file: FileInfo) => {
    setPreviewFile(file)
    try {
      const url = await apiClient.getFileUrl(file.id)
      setPreviewUrl(url)
    } catch {
      setPreviewUrl(null)
    }
  }

  // Edit handlers
  const openEditModal = (file: FileInfo) => {
    setEditingFile(file)
    setEditForm({ name: file.name, category: file.category, isPublic: file.isPublic })
    setEditModalOpen(true)
  }

  const handleUpdate = async () => {
    if (!editingFile) return
    try {
      await apiClient.updateFile(editingFile.id, editForm)
      setEditModalOpen(false)
      await fetchFiles()
    } catch {
      alert('更新失败')
    }
  }

  // Delete handlers
  const handleDelete = async () => {
    if (!fileToDelete) return
    try {
      setIsDeleting(true)
      await apiClient.deleteFile(fileToDelete.id)
      setDeleteConfirmOpen(false)
      setFileToDelete(null)
      await fetchFiles()
    } catch {
      alert('删除失败')
    } finally {
      setIsDeleting(false)
    }
  }

  return (
    <div className="space-y-6 animate-fade-in">
      {/* Upload Area */}
      <Card>
        <CardContent className="p-6">
          <div
            className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
              isDragging ? 'border-sky-500 bg-sky-500/10' : 'border-gray-700 hover:border-gray-500'
            }`}
            onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
            onDragLeave={() => setIsDragging(false)}
            onDrop={handleDrop}
          >
            <Upload className="h-8 w-8 mx-auto mb-3 text-gray-500" />
            <p className="text-gray-400 mb-3">
              {isUploading ? '上传中...' : '拖拽文件到此处,或点击选择文件'}
            </p>
            <div className="flex items-center justify-center gap-4 mb-3">
              <select
                className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white"
                value={uploadCategory}
                onChange={(e) => setUploadCategory(e.target.value)}
              >
                {CATEGORY_OPTIONS.filter(o => o.value).map(opt => (
                  <option key={opt.value} value={opt.value} className="bg-gray-800">{opt.label}</option>
                ))}
              </select>
              <label className="flex items-center gap-2 text-sm text-gray-400">
                <input
                  type="checkbox"
                  checked={uploadIsPublic}
                  onChange={(e) => setUploadIsPublic(e.target.checked)}
                  className="rounded"
                />
                公开
              </label>
            </div>
            <Button
              onClick={() => fileInputRef.current?.click()}
              disabled={isUploading}
            >
              <Upload className="h-4 w-4" />
              {isUploading ? '上传中...' : '选择文件'}
            </Button>
            <input
              ref={fileInputRef}
              type="file"
              multiple
              className="hidden"
              onChange={(e) => handleUpload(e.target.files)}
            />
          </div>
        </CardContent>
      </Card>

      {/* Filters */}
      <Card>
        <CardContent className="p-6">
          <div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
            <div className="flex-1 w-full sm:max-w-md">
              <Input
                placeholder="搜索文件名..."
                value={searchQuery}
                onChange={(e) => { setSearchQuery(e.target.value); setPage(1) }}
                leftIcon={<Search className="h-4 w-4" />}
              />
            </div>
            <select
              className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white"
              value={categoryFilter}
              onChange={(e) => { setCategoryFilter(e.target.value); setPage(1) }}
            >
              {CATEGORY_OPTIONS.map(opt => (
                <option key={opt.value} value={opt.value} className="bg-gray-800">{opt.label}</option>
              ))}
            </select>
            <select
              className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white"
              value={mimeFilter}
              onChange={(e) => { setMimeFilter(e.target.value); setPage(1) }}
            >
              {MIME_FILTER_OPTIONS.map(opt => (
                <option key={opt.value} value={opt.value} className="bg-gray-800">{opt.label}</option>
              ))}
            </select>
          </div>
        </CardContent>
      </Card>

      {/* Error */}
      {error && (
        <div className="p-4 bg-red-500/10 text-red-400 rounded-lg flex justify-between items-center">
          <span>{error}</span>
          <button onClick={fetchFiles} className="underline hover:text-red-300">重试</button>
        </div>
      )}

      {/* File Table */}
      <Card>
        <CardHeader>
          <CardTitle>文件列表 ({total})</CardTitle>
        </CardHeader>
        <CardContent>
          <div className="overflow-x-auto">
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead>文件名</TableHead>
                  <TableHead>分类</TableHead>
                  <TableHead>大小</TableHead>
                  <TableHead>类型</TableHead>
                  <TableHead>公开</TableHead>
                  <TableHead>上传时间</TableHead>
                  <TableHead className="text-right">操作</TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {isLoading ? (
                  <TableRow><TableCell>加载中...</TableCell></TableRow>
                ) : files.length === 0 ? (
                  <TableRow><TableCell>暂无文件</TableCell></TableRow>
                ) : (
                  files.map((file) => (
                    <TableRow key={file.id}>
                      <TableCell>
                        <div className="flex items-center gap-2">
                          {getFileIcon(file.mimeType)}
                          <span className="font-medium text-white truncate max-w-[200px]">{file.name}</span>
                        </div>
                      </TableCell>
                      <TableCell>
                        <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-500/20 text-blue-400 border border-blue-500/30">
                          {file.category}
                        </span>
                      </TableCell>
                      <TableCell className="text-gray-400">{formatFileSize(file.size)}</TableCell>
                      <TableCell className="text-gray-400 text-xs">{file.mimeType}</TableCell>
                      <TableCell>
                        <span className={`text-xs ${file.isPublic ? 'text-green-400' : 'text-gray-500'}`}>
                          {file.isPublic ? '公开' : '私有'}
                        </span>
                      </TableCell>
                      <TableCell className="text-gray-400">
                        {new Date(file.createdAt).toLocaleDateString('zh-CN')}
                      </TableCell>
                      <TableCell className="text-right">
                        <div className="flex items-center justify-end gap-1">
                          <Button variant="ghost" size="sm" onClick={() => handlePreview(file)} title="预览">
                            <Eye className="h-4 w-4" />
                          </Button>
                          <Button variant="ghost" size="sm" onClick={() => openEditModal(file)} title="编辑">
                            <Edit2 className="h-4 w-4" />
                          </Button>
                          {(isSuperAdmin || isAdmin) && (
                            <Button
                              variant="ghost"
                              size="sm"
                              onClick={() => { setFileToDelete(file); setDeleteConfirmOpen(true) }}
                              className="text-red-400 hover:text-red-300"
                              title="删除"
                            >
                              <Trash2 className="h-4 w-4" />
                            </Button>
                          )}
                        </div>
                      </TableCell>
                    </TableRow>
                  ))
                )}
              </TableBody>
            </Table>
          </div>

          {/* Pagination */}
          {total > 20 && (
            <div className="flex justify-center gap-2 mt-4">
              <Button
                variant="outline"
                size="sm"
                disabled={page <= 1}
                onClick={() => setPage(p => p - 1)}
              >
                上一页
              </Button>
              <span className="px-3 py-1.5 text-sm text-gray-400">
                 {page}  /  {Math.ceil(total / 20)} 
              </span>
              <Button
                variant="outline"
                size="sm"
                disabled={page >= Math.ceil(total / 20)}
                onClick={() => setPage(p => p + 1)}
              >
                下一页
              </Button>
            </div>
          )}
        </CardContent>
      </Card>

      {/* Preview Modal */}
      <Modal
        isOpen={!!previewFile}
        onClose={() => { setPreviewFile(null); setPreviewUrl(null) }}
        title={previewFile?.name || '文件预览'}
        size="lg"
      >
        <div className="py-4">
          {previewFile && previewUrl && (
            <>
              {previewFile.mimeType.startsWith('image/') && (
                <img src={previewUrl} alt={previewFile.name} className="max-w-full max-h-[60vh] mx-auto rounded" />
              )}
              {previewFile.mimeType.startsWith('video/') && (
                <video src={previewUrl} controls className="max-w-full max-h-[60vh] mx-auto rounded">
                  您的浏览器不支持视频播放
                </video>
              )}
              {previewFile.mimeType === 'application/pdf' && (
                <iframe src={previewUrl} className="w-full h-[60vh] rounded" title={previewFile.name} />
              )}
              {!previewFile.mimeType.startsWith('image/') &&
               !previewFile.mimeType.startsWith('video/') &&
               previewFile.mimeType !== 'application/pdf' && (
                <div className="text-center py-8">
                  <FileIcon className="h-16 w-16 mx-auto mb-4 text-gray-500" />
                  <p className="text-gray-400 mb-2">{previewFile.name}</p>
                  <p className="text-sm text-gray-500 mb-4">{formatFileSize(previewFile.size)} · {previewFile.mimeType}</p>
                  <a
                    href={previewUrl}
                    download={previewFile.name}
                    className="inline-flex items-center gap-2 px-4 py-2 bg-sky-500 text-white rounded-lg hover:bg-sky-600 transition"
                  >
                    <Download className="h-4 w-4" />
                    下载文件
                  </a>
                </div>
              )}
            </>
          )}
          {previewFile && !previewUrl && (
            <p className="text-center text-gray-500 py-8">加载预览中...</p>
          )}
        </div>
      </Modal>

      {/* Edit Modal */}
      <Modal
        isOpen={editModalOpen}
        onClose={() => setEditModalOpen(false)}
        title="编辑文件信息"
        size="md"
        footer={
          <>
            <Button variant="outline" onClick={() => setEditModalOpen(false)}>取消</Button>
            <Button onClick={handleUpdate}>保存</Button>
          </>
        }
      >
        <div className="space-y-4">
          <Input
            label="文件名"
            value={editForm.name || ''}
            onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
          />
          <div>
            <label className="block text-sm font-medium text-gray-300 mb-1.5">分类</label>
            <select
              className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white"
              value={editForm.category || ''}
              onChange={(e) => setEditForm({ ...editForm, category: e.target.value })}
            >
              {CATEGORY_OPTIONS.filter(o => o.value).map(opt => (
                <option key={opt.value} value={opt.value} className="bg-gray-800">{opt.label}</option>
              ))}
            </select>
          </div>
          <label className="flex items-center gap-2 text-sm text-gray-300">
            <input
              type="checkbox"
              checked={editForm.isPublic || false}
              onChange={(e) => setEditForm({ ...editForm, isPublic: e.target.checked })}
              className="rounded"
            />
            公开文件
          </label>
        </div>
      </Modal>

      {/* Delete Confirm Modal */}
      <Modal
        isOpen={deleteConfirmOpen}
        onClose={() => { setDeleteConfirmOpen(false); setFileToDelete(null) }}
        title="确认删除"
        size="sm"
        footer={
          <>
            <Button variant="outline" onClick={() => { setDeleteConfirmOpen(false); setFileToDelete(null) }} disabled={isDeleting}>取消</Button>
            <Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
              {isDeleting ? '删除中...' : '确认删除'}
            </Button>
          </>
        }
      >
        <div className="py-4">
          <p className="text-gray-300">
            确定要删除文件 <span className="font-medium text-white">{fileToDelete?.name}</span> 吗?
          </p>
          <p className="text-sm text-gray-500 mt-2">文件将从存储中永久删除,此操作不可恢复。</p>
        </div>
      </Modal>
    </div>
  )
}

Step 2: Commit

cd D:\APPS\base\frontend\react-shadcn\pc
git add src/pages/FileManagementPage.tsx
git commit -m "feat: add FileManagementPage with upload, preview, CRUD"

Task 15: Frontend — routing + sidebar integration

Files:

  • Modify: frontend/react-shadcn/pc/src/App.tsx
  • Modify: frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx

Step 1: Add route in App.tsx

In App.tsx, add the import:

import { FileManagementPage } from './pages/FileManagementPage'

Add the route inside the protected MainLayout routes, after users:

<Route path="files" element={<FileManagementPage />} />

Step 2: Add sidebar navigation item

In Sidebar.tsx, add the import for the icon:

import { LayoutDashboard, Users, LogOut, Settings, FolderOpen } from 'lucide-react'

Add to the navItems array, between Users and Settings:

{ path: '/files', icon: FolderOpen, label: '文件管理' },

Step 3: Verify frontend compiles

Run: cd D:\APPS\base\frontend\react-shadcn\pc && npm run build Expected: Build succeeds.

Step 4: Commit

cd D:\APPS\base\frontend\react-shadcn\pc
git add src/App.tsx src/components/layout/Sidebar.tsx
git commit -m "feat: add /files route and sidebar navigation for file management"

Task 16: E2E verification with Playwright

Prerequisites: Backend running on :8888, frontend running on :5173.

Step 1: Login as admin

Navigate to http://localhost:5173/login, log in with admin / admin123.

Step 2: Verify sidebar shows file management

Check sidebar has a "文件管理" link.

Step 3: Navigate to /files

Click "文件管理" in sidebar. Verify the page loads with upload area and empty file table.

Step 4: Upload a file

Click "选择文件" and upload a test image. Verify it appears in the table with correct name, category, size, and type.

Step 5: Preview the file

Click the eye icon on the uploaded file. Verify the preview modal shows the image.

Step 6: Edit the file

Click the edit icon. Change the name and category. Save. Verify the changes appear in the table.

Step 7: Delete the file

Click the trash icon. Confirm deletion. Verify the file is removed from the table.


Summary

Task Description Files
1 Add Go dependencies go.mod, go.sum
2 Config — StorageConfig config.go, base-api.yaml
3 Storage interface + Local impl storage/storage.go, storage/local.go
4 OSS implementation storage/oss.go
5 MinIO implementation storage/minio.go
6 File data model model/file_entity.go, model/file_model.go
7 API definition + goctl gen api/file.api, base.api, types.go, routes.go
8 ServiceContext integration svc/servicecontext.go
9 Upload handler + logic handler/file/upload*, logic/file/upload*
10 Remaining file logic (5 endpoints) logic/file/.go, handler/file/getfileurl
11 Backend curl integration test
12 Frontend types types/index.ts
13 Frontend API client services/api.ts
14 File Management Page pages/FileManagementPage.tsx
15 Routing + sidebar App.tsx, Sidebar.tsx
16 E2E verification