# 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** ```bash 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** ```bash 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: ```go 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: ```go 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`: ```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** ```bash 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`: ```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`: ```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** ```bash 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`: ```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** ```bash 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`: ```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** ```bash 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`: ```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`: ```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** ```bash 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** ```bash 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: ```go 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: ```go // 自动迁移表 err = db.AutoMigrate(&model.User{}, &model.Profile{}, &model.File{}) ``` After `seedCasbinPolicies(enforcer)`, add storage initialization: ```go // 初始化存储 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`: ```go // 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** ```bash 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: ```go 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: ```go 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** ```bash 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`: ```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`: ```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`: ```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`: ```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`: ```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: ```go 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash curl -s "http://localhost:8888/api/v1/file/1" -H "Authorization: Bearer $TOKEN" | jq . ``` **Step 6: Get file URL** ```bash 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** ```bash 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** ```bash 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`: ```typescript // 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** ```bash 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: ```typescript 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: ```typescript // File Management async uploadFile(file: File, category?: string, isPublic?: boolean): Promise> { 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 = {} 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 { 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> { const rawData = await this.request(`/file/${id}`) if ('success' in rawData) return rawData as ApiResponse return { code: 200, message: 'success', success: true, data: rawData } } async getFileUrl(id: number): Promise { // 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 = {} 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> { const rawData = await this.request(`/file/${id}`, { method: 'PUT', body: JSON.stringify(data), }) if ('success' in rawData) return rawData as ApiResponse return { code: 200, message: 'success', success: true, data: rawData } } async deleteFile(id: number): Promise> { return this.request>(`/file/${id}`, { method: 'DELETE', }) } ``` **Step 3: Commit** ```bash 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`: ```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 if (mimeType.startsWith('video/')) return if (mimeType === 'application/pdf') return return } export function FileManagementPage() { const { user: currentUser } = useAuth() const [files, setFiles] = useState([]) 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(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(null) // Edit state const [editModalOpen, setEditModalOpen] = useState(false) const [editingFile, setEditingFile] = useState(null) const [editForm, setEditForm] = useState({}) // Preview state const [previewFile, setPreviewFile] = useState(null) const [previewUrl, setPreviewUrl] = useState(null) // Delete state const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [fileToDelete, setFileToDelete] = useState(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 (
{/* Upload Area */}
{ e.preventDefault(); setIsDragging(true) }} onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} >

{isUploading ? '上传中...' : '拖拽文件到此处,或点击选择文件'}

handleUpload(e.target.files)} />
{/* Filters */}
{ setSearchQuery(e.target.value); setPage(1) }} leftIcon={} />
{/* Error */} {error && (
{error}
)} {/* File Table */} 文件列表 ({total})
文件名 分类 大小 类型 公开 上传时间 操作 {isLoading ? ( 加载中... ) : files.length === 0 ? ( 暂无文件 ) : ( files.map((file) => (
{getFileIcon(file.mimeType)} {file.name}
{file.category} {formatFileSize(file.size)} {file.mimeType} {file.isPublic ? '公开' : '私有'} {new Date(file.createdAt).toLocaleDateString('zh-CN')}
{(isSuperAdmin || isAdmin) && ( )}
)) )}
{/* Pagination */} {total > 20 && (
第 {page} 页 / 共 {Math.ceil(total / 20)} 页
)}
{/* Preview Modal */} { setPreviewFile(null); setPreviewUrl(null) }} title={previewFile?.name || '文件预览'} size="lg" >
{previewFile && previewUrl && ( <> {previewFile.mimeType.startsWith('image/') && ( {previewFile.name} )} {previewFile.mimeType.startsWith('video/') && ( )} {previewFile.mimeType === 'application/pdf' && (