diff --git a/.gitignore b/.gitignore index 5f2423f..17e1cff 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ server/data/ # Web web/node_modules/ web/dist/ +web/.env.local # IDE .idea/ diff --git a/server/api/router.go b/server/api/router.go index f2c9b23..acdb49d 100644 --- a/server/api/router.go +++ b/server/api/router.go @@ -46,6 +46,7 @@ func SetupRouter() *gin.Engine { chunkCtrl := controller.NewChunkUploadController() monitorCtrl := controller.NewMonitorController() openCtrl := controller.NewOpenAPIController() + openFileCtrl := controller.NewOpenAPIFileController() // API路由组 api := r.Group("/api") @@ -54,6 +55,7 @@ func SetupRouter() *gin.Engine { auth := api.Group("/auth") { auth.POST("/login", authCtrl.Login) + auth.POST("/app-login", authCtrl.AppLogin) auth.POST("/register", authCtrl.Register) } @@ -71,6 +73,23 @@ func SetupRouter() *gin.Engine { { openapi.POST("/token/validate", openCtrl.ValidateToken) openapi.POST("/token/generate", openCtrl.GenerateTestToken) + + // 文件操作API (需要JWT认证) + openapi.Use(middleware.JWTAuth(cfg.JWT.Secret)) + { + // 分页查询文件夹内容 + openapi.GET("/files", openFileCtrl.ListByFolder) + // 按名称搜索 + openapi.GET("/files/search", openFileCtrl.SearchTree) + // 下载/预览 + openapi.GET("/files/:id/download", openFileCtrl.Download) + openapi.GET("/files/:id/preview", openFileCtrl.Preview) + // 分片上传 + openapi.POST("/files/upload/init", openFileCtrl.ChunkUploadInit) + openapi.PUT("/files/upload/chunk/:uploadId/:index", openFileCtrl.ChunkUpload) + openapi.POST("/files/upload/merge/:uploadId", openFileCtrl.ChunkMerge) + openapi.GET("/files/upload/progress/:uploadId", openFileCtrl.ChunkProgress) + } } // ============ 需要登录的路由 ============ @@ -91,6 +110,7 @@ func SetupRouter() *gin.Engine { // 分片上传 file.POST("/upload/init", middleware.RequirePermission("file:upload"), chunkCtrl.Init) file.PUT("/upload/chunk/:uploadId/:index", middleware.RequirePermission("file:upload"), chunkCtrl.UploadChunk) + file.POST("/upload/chunk/:uploadId/:index/confirm", middleware.RequirePermission("file:upload"), chunkCtrl.ConfirmChunk) file.POST("/upload/merge/:uploadId", middleware.RequirePermission("file:upload"), chunkCtrl.Merge) file.GET("/upload/progress/:uploadId", chunkCtrl.Progress) file.DELETE("/upload/:uploadId", chunkCtrl.Cancel) @@ -109,12 +129,18 @@ func SetupRouter() *gin.Engine { file.DELETE("/:id", middleware.RequirePermission("file:delete"), fileCtrl.SoftDelete) file.POST("/:id/restore", fileCtrl.Restore) file.DELETE("/:id/permanent", middleware.RequirePermission("file:delete"), fileCtrl.HardDelete) + + // 文件权限管理 + file.GET("/:id/grant", fileCtrl.ListPermissions) + file.POST("/:id/grant", middleware.RequirePermission("file:grant"), fileCtrl.GrantPermission) + file.DELETE("/:id/grant/:permId", middleware.RequirePermission("file:grant"), fileCtrl.RevokePermission) } // 文件夹管理 folder := protected.Group("/folder") { folder.POST("", middleware.RequirePermission("file:create"), folderCtrl.Create) + folder.GET("/by-bizid/:bizid", folderCtrl.GetByBizID) folder.GET("/:id/children", folderCtrl.ListChildren) folder.GET("/root", func(c *gin.Context) { c.Params = append(c.Params, gin.Param{Key: "id", Value: "root"}) @@ -196,6 +222,7 @@ func SetupRouter() *gin.Engine { admin.GET("/monitor/traffic", monitorCtrl.GetTrafficStats) admin.GET("/monitor/requests", monitorCtrl.GetRecentRequests) admin.GET("/monitor/storage", monitorCtrl.GetStorageStats) + admin.GET("/monitor/logs", monitorCtrl.GetSystemLogs) // IP黑名单 admin.GET("/blacklist", monitorCtrl.GetBlacklist) diff --git a/server/config.yaml b/server/config.yaml index 81e8dcd..859eff4 100644 --- a/server/config.yaml +++ b/server/config.yaml @@ -3,11 +3,12 @@ # ============================================= server: - port: 8080 + port: 8075 mode: debug - public_url: "http://localhost:5173" + public_url: "http://127.0.0.1:5173" database: + type: mysql # mysql / sqlserver / postgres host: 127.0.0.1 port: 3306 username: root @@ -21,10 +22,13 @@ jwt: storage: default_policy_id: 1 local_base_path: D:/Seeyon/A8/localfile - cdn_host: "http://localhost:3090" + cdn_host: "http://127.0.0.1:3090" cdn_path_prefix: "/public" # 备份工具路径(留空则自动搜索) backup: - mysqldump_path: "C:/Program Files/MySQL/MySQL Server 8.0/bin/mysqldump.exe" - mysql_path: "C:/Program Files/MySQL/MySQL Server 8.0/bin/mysql.exe" + mysqldump_path: "" + mysql_path: "" + pgdump_path: "" + psql_path: "" + sqlcmd_path: "" diff --git a/server/config/config.go b/server/config/config.go index 6cbd85e..1b381d1 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -23,8 +23,14 @@ type Config struct { // BackupConfig 备份工具配置 type BackupConfig struct { - MysqlDumpPath string `mapstructure:"mysqldump_path"` // mysqldump 路径 - MysqlPath string `mapstructure:"mysql_path"` // mysql 路径 + // MySQL + MysqlDumpPath string `mapstructure:"mysqldump_path"` + MysqlPath string `mapstructure:"mysql_path"` + // PostgreSQL + PgDumpPath string `mapstructure:"pgdump_path"` + PsqlPath string `mapstructure:"psql_path"` + // SQL Server + SqlCmdPath string `mapstructure:"sqlcmd_path"` } // ServerConfig 服务器配置 @@ -36,6 +42,7 @@ type ServerConfig struct { // DatabaseConfig 数据库配置 type DatabaseConfig struct { + Type string `mapstructure:"type"` // 数据库类型: mysql / sqlserver / postgres Host string `mapstructure:"host"` Port int `mapstructure:"port"` Username string `mapstructure:"username"` @@ -43,10 +50,22 @@ type DatabaseConfig struct { DBName string `mapstructure:"dbname"` } -// DSN 返回数据库连接字符串 +// DSN 返回数据库连接字符串(根据类型生成不同格式) func (d *DatabaseConfig) DSN() string { - return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", - d.Username, d.Password, d.Host, d.Port, d.DBName) + switch d.Type { + case "sqlserver": + // SQL Server: sqlserver://user:pass@host:port?database=db名 + return fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s", + d.Username, d.Password, d.Host, d.Port, d.DBName) + case "postgres": + // PostgreSQL: host=localhost port=5432 user=x password=x dbname=x sslmode=disable + return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable TimeZone=Asia/Shanghai", + d.Host, d.Port, d.Username, d.Password, d.DBName) + default: + // MySQL: user:pass@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", + d.Username, d.Password, d.Host, d.Port, d.DBName) + } } // JWTConfig JWT配置 @@ -65,6 +84,7 @@ type StorageConfig struct { // Load 加载配置文件 // 配置文件搜索路径: ./config.yaml, ./config/config.yaml +// 支持环境变量覆盖: SERVER_PORT, SERVER_MODE, DB_HOST, DB_PORT, DB_PASSWORD, DB_NAME 等 func Load() (*Config, error) { viper.SetConfigName("config") viper.SetConfigType("yaml") @@ -75,6 +95,7 @@ func Load() (*Config, error) { // 设置默认值 setDefaults() + // 读取配置文件 if err := viper.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { return nil, fmt.Errorf("读取配置文件失败: %w", err) @@ -82,6 +103,22 @@ func Load() (*Config, error) { log.Println("未找到配置文件, 使用默认配置") } + // 支持环境变量覆盖(优先级: 环境变量 > 配置文件 > 默认值) + viper.SetEnvPrefix("FS") + viper.AutomaticEnv() + + // 环境变量映射 + viper.BindEnv("server.port", "FS_SERVER_PORT") + viper.BindEnv("server.mode", "FS_SERVER_MODE") + viper.BindEnv("server.public_url", "FS_PUBLIC_URL") + viper.BindEnv("database.type", "FS_DB_TYPE") + viper.BindEnv("database.host", "FS_DB_HOST") + viper.BindEnv("database.port", "FS_DB_PORT") + viper.BindEnv("database.username", "FS_DB_USER") + viper.BindEnv("database.password", "FS_DB_PASSWORD") + viper.BindEnv("database.dbname", "FS_DB_NAME") + viper.BindEnv("jwt.secret", "FS_JWT_SECRET") + AppConfig = &Config{} if err := viper.Unmarshal(AppConfig); err != nil { return nil, fmt.Errorf("解析配置失败: %w", err) @@ -97,6 +134,7 @@ func setDefaults() { viper.SetDefault("server.mode", "debug") // 数据库默认配置 + viper.SetDefault("database.type", "mysql") viper.SetDefault("database.host", "127.0.0.1") viper.SetDefault("database.port", 3306) viper.SetDefault("database.username", "root") diff --git a/server/config/database.go b/server/config/database.go index 083aa9a..5a674fc 100644 --- a/server/config/database.go +++ b/server/config/database.go @@ -7,6 +7,8 @@ import ( "time" "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlserver" "gorm.io/gorm" "gorm.io/gorm/logger" ) @@ -15,11 +17,22 @@ import ( var DB *gorm.DB // InitDB 初始化数据库连接 +// 支持: mysql / sqlserver / postgres func InitDB(cfg *DatabaseConfig) error { dsn := cfg.DSN() + var dialector gorm.Dialector + switch cfg.Type { + case "sqlserver": + dialector = sqlserver.Open(dsn) + case "postgres": + dialector = postgres.Open(dsn) + default: + dialector = mysql.Open(dsn) + } + var err error - DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ + DB, err = gorm.Open(dialector, &gorm.Config{ Logger: logger.Default.LogMode(logger.Info), }) if err != nil { @@ -37,6 +50,6 @@ func InitDB(cfg *DatabaseConfig) error { sqlDB.SetMaxOpenConns(100) // 最大打开连接数 sqlDB.SetConnMaxLifetime(time.Hour) // 连接最大存活时间 - log.Println("数据库连接成功") + log.Printf("数据库连接成功 [%s]", cfg.Type) return nil } diff --git a/server/controller/auth_controller.go b/server/controller/auth_controller.go index 804acd1..aa7a417 100644 --- a/server/controller/auth_controller.go +++ b/server/controller/auth_controller.go @@ -41,6 +41,24 @@ func (c *AuthController) Login(ctx *gin.Context) { utils.Success(ctx, resp) } +// AppLogin 应用登录(用户名+appId) +// POST /api/auth/app-login +func (c *AuthController) AppLogin(ctx *gin.Context) { + var req dto.AppLoginRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + resp, err := c.authService.AppLogin(&req) + if err != nil { + utils.Unauthorized(ctx, err.Error()) + return + } + + utils.Success(ctx, resp) +} + // Register 用户注册 // POST /api/auth/register func (c *AuthController) Register(ctx *gin.Context) { diff --git a/server/controller/chunk_upload_controller.go b/server/controller/chunk_upload_controller.go index d1d96df..6822eb5 100644 --- a/server/controller/chunk_upload_controller.go +++ b/server/controller/chunk_upload_controller.go @@ -33,7 +33,7 @@ func (c *ChunkUploadController) Init(ctx *gin.Context) { return } - result, err := c.chunkService.InitUpload(req.FileName, req.FileSize, req.MD5, req.FolderID, ownerID, req.PolicyID) + result, err := c.chunkService.InitUpload(req.FileName, req.FileSize, req.MD5, uint64(req.FolderID), ownerID, req.PolicyID) if err != nil { utils.ServerError(ctx, err.Error()) return @@ -79,6 +79,32 @@ func (c *ChunkUploadController) UploadChunk(ctx *gin.Context) { utils.Success(ctx, result) } +// ConfirmChunk 确认MinIO分片上传完成(客户端直传后调用) +// POST /api/file/upload/chunk/:uploadId/:index/confirm +func (c *ChunkUploadController) ConfirmChunk(ctx *gin.Context) { + uploadID := ctx.Param("uploadId") + indexStr := ctx.Param("index") + + var index int + if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil { + utils.BadRequest(ctx, "分片索引无效") + return + } + + var req struct { + ETag string `json:"etag"` + } + ctx.ShouldBindJSON(&req) + + result, err := c.chunkService.UploadChunkMinIO(uploadID, index, req.ETag) + if err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + // Merge 合并分片完成上传 // POST /api/file/upload/merge/:uploadId func (c *ChunkUploadController) Merge(ctx *gin.Context) { diff --git a/server/controller/file_controller.go b/server/controller/file_controller.go index 717cca2..10db022 100644 --- a/server/controller/file_controller.go +++ b/server/controller/file_controller.go @@ -37,12 +37,24 @@ func (c *FileController) Upload(ctx *gin.Context) { return } - // 获取目标文件夹ID - folderIDStr := ctx.DefaultQuery("folder_id", "0") + // 获取目标文件夹ID(优先查询参数, 其次表单字段) + folderIDStr := ctx.DefaultQuery("folder_id", "") + if folderIDStr == "" { + folderIDStr = ctx.PostForm("folder_id") + } + if folderIDStr == "" { + folderIDStr = "0" + } folderID, _ := strconv.ParseUint(folderIDStr, 10, 64) // 获取存储策略ID(可选, 0=使用默认策略) - policyIDStr := ctx.DefaultQuery("storage_policy_id", "0") + policyIDStr := ctx.DefaultQuery("storage_policy_id", "") + if policyIDStr == "" { + policyIDStr = ctx.PostForm("storage_policy_id") + } + if policyIDStr == "" { + policyIDStr = "0" + } storagePolicyID, _ := strconv.ParseUint(policyIDStr, 10, 64) // 调用服务层 @@ -108,7 +120,8 @@ func (c *FileController) GetDownloadURL(ctx *gin.Context) { return } - downloadURL, err := c.fileService.GetDownloadURL(id) + token := ctx.GetHeader("token") + downloadURL, err := c.fileService.GetDownloadURL(id, token) if err != nil { utils.NotFound(ctx, err.Error()) return @@ -126,7 +139,9 @@ func (c *FileController) GetPreviewURL(ctx *gin.Context) { return } - previewURL, err := c.fileService.GetPreviewURL(id) + token := ctx.GetHeader("token") + + previewURL, err := c.fileService.GetPreviewURL(id, token) if err != nil { utils.NotFound(ctx, err.Error()) return @@ -221,14 +236,14 @@ func (c *FileController) Move(ctx *gin.Context) { } var req struct { - TargetFolderID uint64 `json:"target_folder_id" binding:"required"` + TargetFolderID dto.StringID `json:"target_folder_id" binding:"required"` } if err := ctx.ShouldBindJSON(&req); err != nil { utils.BadRequest(ctx, "参数错误: "+err.Error()) return } - if err := c.fileService.Move(id, req.TargetFolderID, ownerID); err != nil { + if err := c.fileService.Move(id, uint64(req.TargetFolderID), ownerID); err != nil { utils.ServerError(ctx, err.Error()) return } @@ -354,3 +369,73 @@ func (c *FileController) ListTrashed(ctx *gin.Context) { utils.Success(ctx, results) } + +// GrantPermission 授权文件权限 +// POST /api/file/:id/grant +func (c *FileController) GrantPermission(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + var req dto.GrantPermissionRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.fileService.GrantPermission(id, ownerID, &req); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "授权成功", nil) +} + +// RevokePermission 撤销文件权限 +// DELETE /api/file/:id/grant/:permId +func (c *FileController) RevokePermission(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + fileID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + permID, err := strconv.ParseUint(ctx.Param("permId"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的权限ID") + return + } + + if err := c.fileService.RevokePermission(fileID, permID, ownerID); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "已撤销", nil) +} + +// ListPermissions 查看文件的授权列表 +// GET /api/file/:id/grant +func (c *FileController) ListPermissions(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + fileID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + results, err := c.fileService.ListPermissions(fileID, ownerID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, results) +} diff --git a/server/controller/folder_controller.go b/server/controller/folder_controller.go index 4d3952a..ae78ecb 100644 --- a/server/controller/folder_controller.go +++ b/server/controller/folder_controller.go @@ -44,8 +44,27 @@ func (c *FolderController) Create(ctx *gin.Context) { utils.Success(ctx, result) } -// ListChildren 获取文件夹子项 -// GET /api/folder/:id/children 或 GET /api/folder/root +// GetByBizID 根据业务ID获取文件夹信息 +// GET /api/folder/by-bizid/:bizid +func (c *FolderController) GetByBizID(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + bizID := ctx.Param("bizid") + if bizID == "" { + utils.BadRequest(ctx, "业务ID不能为空") + return + } + + result, err := c.folderService.GetByBizID(bizID, ownerID) + if err != nil { + utils.NotFound(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// ListChildren 获取文件夹子项(支持分页) +// GET /api/folder/:id/children?page=1&page_size=50 func (c *FolderController) ListChildren(ctx *gin.Context) { ownerID := middleware.GetCurrentUserID(ctx) @@ -62,7 +81,17 @@ func (c *FolderController) ListChildren(ctx *gin.Context) { } } - result, err := c.folderService.ListChildren(folderID, ownerID) + // 分页参数 + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(ctx.DefaultQuery("page_size", "50")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 200 { + pageSize = 50 + } + + result, err := c.folderService.ListChildren(folderID, ownerID, page, pageSize) if err != nil { utils.ServerError(ctx, err.Error()) return diff --git a/server/controller/monitor_controller.go b/server/controller/monitor_controller.go index 55a40b3..ef2c479 100644 --- a/server/controller/monitor_controller.go +++ b/server/controller/monitor_controller.go @@ -10,6 +10,24 @@ import ( "github.com/gin-gonic/gin" ) +// GetSystemLogs 获取系统运行日志 +// GET /api/admin/monitor/logs?limit=200 +func (c *MonitorController) GetSystemLogs(ctx *gin.Context) { + limit := 200 + if l, err := parseIntQuery(ctx, "limit"); err == nil && l > 0 { + limit = l + } + if limit > 2000 { + limit = 2000 + } + + entries := utils.LogBuf.Get(limit) + utils.Success(ctx, gin.H{ + "total": utils.LogBuf.Count(), + "entries": entries, + }) +} + // MonitorController 监控控制器 type MonitorController struct { fileRepo *repository.FileRepository diff --git a/server/controller/openapi_file_controller.go b/server/controller/openapi_file_controller.go new file mode 100644 index 0000000..e1a6075 --- /dev/null +++ b/server/controller/openapi_file_controller.go @@ -0,0 +1,374 @@ +package controller + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "seeyon-filesystem/config" + "seeyon-filesystem/dto" + "seeyon-filesystem/middleware" + "seeyon-filesystem/model" + "seeyon-filesystem/service" + "seeyon-filesystem/storage" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// OpenAPIFileController 对外文件API控制器 +type OpenAPIFileController struct { + fileService *service.FileService + folderService *service.FolderService + chunkService *service.ChunkUploadService +} + +func NewOpenAPIFileController() *OpenAPIFileController { + return &OpenAPIFileController{ + fileService: service.NewFileService(), + folderService: service.NewFolderService(), + chunkService: service.NewChunkUploadService(), + } +} + +// ========== 1. 根据父文件夹ID分页查询 ========== + +// ListByFolder 分页查询文件夹下的文件和子文件夹 +// GET /api/openapi/files?folder_id=0&page=1&page_size=20 +// 返回树状结构: 文件夹 + 文件 +func (c *OpenAPIFileController) ListByFolder(ctx *gin.Context) { + userID := middleware.GetCurrentUserID(ctx) + + folderIDStr := ctx.DefaultQuery("folder_id", "0") + folderID, _ := strconv.ParseUint(folderIDStr, 10, 64) + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(ctx.DefaultQuery("page_size", "50")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 200 { + pageSize = 50 + } + + // 获取用户角色 + roleIDs := getUserRoleIDs(userID) + + // 查询文件夹 + folders, err := getFoldersByParent(folderID, userID, page, pageSize) + if err != nil { + utils.ServerError(ctx, "查询文件夹失败") + return + } + + // 查询文件 + files, totalFiles, err := getFilesByFolder(folderID, userID, roleIDs, page, pageSize) + if err != nil { + utils.ServerError(ctx, "查询文件失败") + return + } + + // 组装树状结构 + result := dto.OpenAPIListResult{ + FolderID: folderID, + TotalFiles: totalFiles, + Page: page, + PageSize: pageSize, + } + + for _, f := range folders { + childCount := getFolderChildCount(f.ID) + result.Folders = append(result.Folders, dto.OpenAPIFolderVO{ + ID: dto.StringID(f.ID), + Name: f.Name, + ParentID: dto.StringID(f.ParentID), + ChildCount: childCount, + CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + + for _, f := range files { + result.Files = append(result.Files, dto.OpenAPIFileVO{ + ID: dto.StringID(f.ID), + Name: f.Name, + Extension: f.Extension, + Size: f.Size, + MD5: f.MD5, + MimeType: f.MimeType, + DownloadURL: getCDNOrPresignedURL(&f), + CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + + utils.Success(ctx, result) +} + +// ========== 2. 按名称搜索返回树状结构 ========== + +// SearchTree 按文件名/文件夹名搜索, 返回树状结构 +// GET /api/openapi/files/search?keyword=报告&page=1&page_size=20 +func (c *OpenAPIFileController) SearchTree(ctx *gin.Context) { + userID := middleware.GetCurrentUserID(ctx) + keyword := ctx.Query("keyword") + if keyword == "" { + utils.BadRequest(ctx, "请输入搜索关键词") + return + } + + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(ctx.DefaultQuery("page_size", "50")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 200 { + pageSize = 50 + } + + roleIDs := getUserRoleIDs(userID) + + // 搜索文件 + files, total, err := searchFilesByName(userID, roleIDs, keyword, page, pageSize) + if err != nil { + utils.ServerError(ctx, "搜索失败") + return + } + + // 搜索文件夹 + folders, err := searchFoldersByName(userID, keyword, page, pageSize) + if err != nil { + utils.ServerError(ctx, "搜索失败") + return + } + + result := dto.OpenAPISearchResult{ + Keyword: keyword, + TotalFiles: total, + Page: page, + PageSize: pageSize, + } + + for _, f := range folders { + result.Folders = append(result.Folders, dto.OpenAPIFolderVO{ + ID: dto.StringID(f.ID), + Name: f.Name, + ParentID: dto.StringID(f.ParentID), + CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + + for _, f := range files { + result.Files = append(result.Files, dto.OpenAPIFileVO{ + ID: dto.StringID(f.ID), + Name: f.Name, + Extension: f.Extension, + Size: f.Size, + MD5: f.MD5, + MimeType: f.MimeType, + DownloadURL: getCDNOrPresignedURL(&f), + CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + + utils.Success(ctx, result) +} + +// ========== 3. 分片上传/下载 ========== + +// ChunkUploadInit 初始化分片上传 +// POST /api/openapi/files/upload/init +func (c *OpenAPIFileController) ChunkUploadInit(ctx *gin.Context) { + userID := middleware.GetCurrentUserID(ctx) + + var req dto.ChunkUploadInitRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + result, err := c.chunkService.InitUpload(req.FileName, req.FileSize, req.MD5, uint64(req.FolderID), userID, req.PolicyID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// ChunkUpload 上传分片 +// PUT /api/openapi/files/upload/chunk/:uploadId/:index +func (c *OpenAPIFileController) ChunkUpload(ctx *gin.Context) { + uploadID := ctx.Param("uploadId") + indexStr := ctx.Param("index") + index, err := strconv.Atoi(indexStr) + if err != nil { + utils.BadRequest(ctx, "分片索引无效") + return + } + + checksum := ctx.GetHeader("X-Checksum") + + file, err := ctx.FormFile("chunk") + if err != nil { + utils.BadRequest(ctx, "请上传分片文件") + return + } + + reader, err := file.Open() + if err != nil { + utils.BadRequest(ctx, "读取分片失败") + return + } + defer reader.Close() + + result, err := c.chunkService.UploadChunk(uploadID, index, reader, checksum) + if err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// ChunkMerge 合并分片 +// POST /api/openapi/files/upload/merge/:uploadId +func (c *OpenAPIFileController) ChunkMerge(ctx *gin.Context) { + uploadID := ctx.Param("uploadId") + + result, err := c.chunkService.MergeChunks(uploadID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// ChunkProgress 查询上传进度 +// GET /api/openapi/files/upload/progress/:uploadId +func (c *OpenAPIFileController) ChunkProgress(ctx *gin.Context) { + uploadID := ctx.Param("uploadId") + + result, err := c.chunkService.GetUploadProgress(uploadID) + if err != nil { + utils.NotFound(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// Download 下载文件 +// GET /api/openapi/files/:id/download +func (c *OpenAPIFileController) Download(ctx *gin.Context) { + userID := middleware.GetCurrentUserID(ctx) + fileID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + reader, file, err := c.fileService.Download(fileID, userID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + defer reader.Close() + + ctx.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", file.Name)) + ctx.Header("Content-Type", file.MimeType) + ctx.Header("Content-Length", strconv.FormatInt(file.Size, 10)) + ctx.DataFromReader(http.StatusOK, file.Size, file.MimeType, reader, nil) +} + +// Preview 预览文件 +// GET /api/openapi/files/:id/preview +func (c *OpenAPIFileController) Preview(ctx *gin.Context) { + userID := middleware.GetCurrentUserID(ctx) + fileID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + reader, file, err := c.fileService.Download(fileID, userID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + defer reader.Close() + + ctx.Header("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", file.Name)) + ctx.Header("Content-Type", file.MimeType) + ctx.Header("Content-Length", strconv.FormatInt(file.Size, 10)) + ctx.DataFromReader(http.StatusOK, file.Size, file.MimeType, reader, nil) +} + +// ========== 辅助函数 ========== + +func getUserRoleIDs(userID uint64) []uint64 { + var userRoles []model.UserRole + config.DB.Where("user_id = ?", userID).Find(&userRoles) + ids := make([]uint64, 0, len(userRoles)) + for _, ur := range userRoles { + ids = append(ids, ur.RoleID) + } + return ids +} + +func getFoldersByParent(parentID, ownerID uint64, page, pageSize int) ([]model.Folder, error) { + var folders []model.Folder + err := config.DB.Where("parent_id = ? AND owner_id = ? AND status = 1", parentID, ownerID). + Order("name ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&folders).Error + return folders, err +} + +func getFilesByFolder(folderID, userID uint64, roleIDs []uint64, page, pageSize int) ([]model.File, int64, error) { + var files []model.File + query := config.DB.Where("folder_id = ? AND owner_id = ? AND status = 1", folderID, userID) + var total int64 + query.Model(&model.File{}).Count(&total) + err := query.Order("name ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&files).Error + return files, total, err +} + +func getFolderChildCount(folderID uint64) int64 { + var count int64 + config.DB.Model(&model.Folder{}).Where("parent_id = ? AND status = 1", folderID).Count(&count) + return count +} + +func searchFilesByName(userID uint64, roleIDs []uint64, keyword string, page, pageSize int) ([]model.File, int64, error) { + var files []model.File + query := config.DB.Where("owner_id = ? AND status = 1 AND name LIKE ?", userID, "%"+keyword+"%") + var total int64 + query.Model(&model.File{}).Count(&total) + err := query.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&files).Error + return files, total, err +} + +func searchFoldersByName(ownerID uint64, keyword string, page, pageSize int) ([]model.Folder, error) { + var folders []model.Folder + err := config.DB.Where("owner_id = ? AND status = 1 AND name LIKE ?", ownerID, "%"+keyword+"%"). + Order("name ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&folders).Error + return folders, err +} + +func getCDNOrPresignedURL(file *model.File) string { + if file.StorageKey == "" { + return "" + } + engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID) + if err != nil { + return "" + } + if localEngine, ok := engine.(*storage.LocalEngine); ok { + return localEngine.GetURL(file.StorageKey) + } + if minioEngine, ok := engine.(*storage.MinIOEngine); ok { + url, err := minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour) + if err == nil { + return url + } + } + return "" +} diff --git a/server/deploy.bat b/server/deploy.bat index f6e05d2..1abf868 100644 --- a/server/deploy.bat +++ b/server/deploy.bat @@ -1,135 +1,4 @@ -@echo off -chcp 65001 >nul -echo ============================================ -echo 文件管理系统 - Windows 部署脚本 -echo ============================================ -echo. - -:: 设置部署目录 -set DEPLOY_DIR=%~dp0deploy -set SERVER_DIR=%DEPLOY_DIR%\server -set FRONTEND_DIR=%DEPLOY_DIR%\frontend - -:: 清理旧部署 -if exist "%DEPLOY_DIR%" ( - echo [1/6] 清理旧部署目录... - rmdir /s /q "%DEPLOY_DIR%" -) - -:: 创建目录结构 -echo [2/6] 创建目录结构... -mkdir "%SERVER_DIR%" -mkdir "%SERVER_DIR%\data\storage" -mkdir "%FRONTEND_DIR%" - -:: 编译后端 -echo [3/6] 编译后端... -cd /d "%~dp0" -set GOPROXY=https://goproxy.cn,direct -go build -o "%SERVER_DIR%\server.exe" main.go -if errorlevel 1 ( - echo 编译失败! - pause - exit /b 1 -) - -:: 复制后端文件 -echo [4/6] 复制后端文件... -copy /y config.yaml "%SERVER_DIR%\" >nul -copy /y config.yaml "%SERVER_DIR%\config.yaml.example" >nul -xcopy /s /e /y sql "%SERVER_DIR%\sql\" >nul - -:: 构建前端 -echo [5/6] 构建前端... -cd /d "%~dp0..\web" -call npm run build -if errorlevel 1 ( - echo 前端构建失败! - pause - exit /b 1 -) -xcopy /s /e /y dist "%FRONTEND_DIR%\" >nul - -:: 生成启动脚本 -echo [6/6] 生成启动脚本... - -:: 后端启动脚本 -( -echo @echo off -echo chcp 65001 ^>nul -echo echo ============================================ -echo echo 文件管理系统 - 后端服务 -echo echo ============================================ -echo echo. -echo echo 启动中... 访问 http://localhost:8080/health 检查状态 -echo echo. -echo server.exe -echo pause -) > "%DEPLOY_DIR%\启动后端.bat" - -:: 数据库初始化脚本 -( -echo @echo off -echo chcp 65001 ^>nul -echo echo ============================================ -echo echo 数据库初始化 -echo echo ============================================ -echo echo. -echo set /p MYSQL_PWD=请输入MySQL root密码: -echo echo. -echo echo 正在创建数据库... -echo mysql -u root -p%%MYSQL_PWD%% -e "CREATE DATABASE IF NOT EXISTS seeyon_fs DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" -echo echo. -echo echo 正在导入表结构... -echo mysql -u root -p%%MYSQL_PWD%% seeyon_fs ^< sql\init.sql -echo echo. -echo echo 初始化完成! -echo pause -) > "%DEPLOY_DIR%\初始化数据库.bat" - -:: 使用说明 -( -echo ============================================ -echo 文件管理系统 - 部署说明 -echo ============================================ -echo. -echo 【目录结构】 -echo deploy/ -echo ├── server/ -echo │ ├── server.exe 后端服务 -echo │ ├── config.yaml 配置文件(需修改) -echo │ ├── config.yaml.example 配置示例 -echo │ ├── data/storage/ 文件存储目录 -echo │ └── sql/init.sql 数据库脚本 -echo ├── frontend/ 前端静态资源 -echo ├── 启动后端.bat 启动后端服务 -echo └── 初始化数据库.bat 首次部署执行 -echo. -echo 【部署步骤】 -echo 1. 安装MySQL 8.0, 运行"初始化数据库.bat" -echo 2. 修改server/config.yaml中的数据库密码等配置 -echo 3. 运行"启动后端.bat" -echo 4. 配置Nginx(参考docs/nginx-production.conf) -echo 5. 访问 http://你的域名 -echo. -echo 【默认账号】admin / admin123 -echo. -) > "%DEPLOY_DIR%\部署说明.txt" - -echo. -echo ============================================ -echo 部署完成! -echo ============================================ -echo. -echo 部署目录: %DEPLOY_DIR% -echo. -echo 文件清单: -dir /s /b "%DEPLOY_DIR%\*.exe" "%DEPLOY_DIR%\*.yaml" "%DEPLOY_DIR%\*.bat" "%DEPLOY_DIR%\*.txt" 2>nul -echo. -echo 下一步: -echo 1. 将 %DEPLOY_DIR% 复制到目标服务器 -echo 2. 运行"初始化数据库.bat" -echo 3. 修改 config.yaml 配置 -echo 4. 运行"启动后端.bat" -echo. -pause +set CGO_ENABLED=0 +set GOOS=linux +set GOARCH=amd64 +$env:CGO_ENABLED="0"; $env:GOOS="linux"; $env:GOARCH="amd64"; go build -ldflags "-s -w" -o myapp main.go \ No newline at end of file diff --git a/server/deploy.sh b/server/deploy.sh index 39a4191..21e538c 100644 --- a/server/deploy.sh +++ b/server/deploy.sh @@ -1,102 +1,189 @@ #!/bin/bash # ============================================ -# 文件管理系统 - Linux 部署脚本 +# 文件管理系统 - 本地构建脚本 +# 作用:只构建部署产物,不依赖服务器环境 # ============================================ set -e DEPLOY_DIR="./deploy" -SERVER_DIR="$DEPLOY_DIR/server" -FRONTEND_DIR="$DEPLOY_DIR/frontend" echo "============================================" -echo " 文件管理系统 - Linux 部署" +echo " 本地构建部署产物" echo "============================================" -# 清理旧部署 +# 清理旧包 [ -d "$DEPLOY_DIR" ] && rm -rf "$DEPLOY_DIR" -# 创建目录 -echo "[1/6] 创建目录结构..." -mkdir -p "$SERVER_DIR/data/storage" -mkdir -p "$FRONTEND_DIR" +# 创建目录结构 +echo "[1/4] 创建目录结构..." +mkdir -p "$DEPLOY_DIR/data/storage/{hot,cold,public,slave}" -# 编译后端 -echo "[2/6] 编译后端..." +# 编译(本地编译,去掉调试信息) +echo "[2/4] 编译后端..." export GOPROXY=https://goproxy.cn,direct -go build -o "$SERVER_DIR/server" main.go +go build -ldflags="-s -w" -o "$DEPLOY_DIR/server" main.go -# 复制后端文件 -echo "[3/6] 复制后端文件..." -cp config.yaml "$SERVER_DIR/" -cp config.yaml "$SERVER_DIR/config.yaml.example" -cp -r sql "$SERVER_DIR/" +# 复制配置与SQL +echo "[3/4] 复制配置文件..." +cp config.yaml "$DEPLOY_DIR/config.yaml.example" +cp -r sql "$DEPLOY_DIR/" -# 构建前端 -echo "[4/6] 构建前端..." -cd ../web && npm run build && cd ../server -cp -r ../web/dist/* "$FRONTEND_DIR/" +# 生成启动脚本、数据库初始化脚本、systemd服务 +echo "[4/4] 生成启动/服务脚本..." -# 生成启动脚本 -echo "[5/6] 生成启动脚本..." - -cat > "$DEPLOY_DIR/start.sh" << 'EOF' +# 启动脚本(里面会自动安装Go) +cat > "$DEPLOY_DIR/start.sh" <<'STARTEOF' #!/bin/bash -cd "$(dirname "$0")/server" -echo "启动后端服务..." +set -e +cd "$(dirname "$0")" +RUN_DIR=$(pwd) + +echo "============================================" +echo " 服务启动脚本(自动安装Go环境)" +echo "============================================" + +# ========== Go 版本配置 ========== +GO_VERSION="1.23.6" +INSTALL_DIR="/usr/local" +GO_TAR_DIR="${INSTALL_DIR}/go" +GO_BIN="${GO_TAR_DIR}/bin/go" + +# ========== 安装/加载 Go ========== +install_go() { + echo "[1/3] 准备 Go ${GO_VERSION} 环境..." + ARCH=$(uname -m) + case $ARCH in + x86_64) GO_ARCH="amd64" ;; + aarch64) GO_ARCH="arm64" ;; + *) echo "不支持的架构: $ARCH"; exit 1 ;; + esac + + GO_TAR="go${GO_VERSION}.linux-${GO_ARCH}.tar.gz" + GO_URL_MIRROR="https://golang.google.cn/dl/${GO_TAR}" + GO_URL_OFFICIAL="https://go.dev/dl/${GO_TAR}" + + echo "下载 Go ${GO_VERSION}..." + DOWNLOAD_OK=0 + for URL in "$GO_URL_MIRROR" "$GO_URL_OFFICIAL"; do + echo "尝试: $URL" + if command -v curl &>/dev/null; then + curl -L --connect-timeout 15 --retry 2 -o "/tmp/${GO_TAR}" "$URL" && DOWNLOAD_OK=1 && break + else + wget --timeout=15 -O "/tmp/${GO_TAR}" "$URL" && DOWNLOAD_OK=1 && break + fi + done + + if [ "$DOWNLOAD_OK" -eq 0 ] || [ ! -s "/tmp/${GO_TAR}" ]; then + echo "下载失败,请手动安装 Go" + exit 1 + fi + + echo "解压..." + rm -rf "$GO_TAR_DIR" + tar -C "$INSTALL_DIR" -xzf "/tmp/${GO_TAR}" + rm -f "/tmp/${GO_TAR}" +} + +# 检查是否已有Go +if ! command -v go &>/dev/null; then + if [ -f "$GO_BIN" ]; then + export PATH=$PATH:$GO_TAR_DIR/bin + else + install_go + export PATH=$PATH:$GO_TAR_DIR/bin + fi +fi + +echo "[2/3] Go 版本: $(go version)" + +# 检查配置文件 +echo "[3/3] 检查配置..." +if [ ! -f "config.yaml" ]; then + echo "未找到 config.yaml,从模板复制..." + cp config.yaml.example config.yaml + echo "请编辑 config.yaml 后重新启动" + exit 1 +fi + +echo "启动服务..." +echo "工作目录: $RUN_DIR" ./server -EOF +STARTEOF chmod +x "$DEPLOY_DIR/start.sh" -cat > "$DEPLOY_DIR/init-db.sh" << 'EOF' +# 数据库初始化脚本 +cat > "$DEPLOY_DIR/init-db.sh" <<'DBEOF' #!/bin/bash -cd "$(dirname "$0")/server" -read -sp "请输入MySQL root密码: " MYSQL_PWD -echo "" -echo "创建数据库..." -mysql -u root -p"$MYSQL_PWD" -e "CREATE DATABASE IF NOT EXISTS seeyon_fs DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" -echo "导入表结构..." -mysql -u root -p"$MYSQL_PWD" seeyon_fs < sql/init.sql -echo "初始化完成!" -EOF +cd "$(dirname "$0")" + +echo "============================================" +echo " 数据库初始化" +echo "============================================" +echo "1) MySQL" +echo "2) PostgreSQL" +echo "3) SQL Server" +read -p "请选择数据库类型 [1/2/3]: " DB_TYPE + +case $DB_TYPE in + 1) + read -sp "MySQL root 密码: " DB_PWD; echo + read -p "数据库名 [seeyon_fs]: " DB_NAME; DB_NAME=${DB_NAME:-seeyon_fs} + mysql -u root -p"$DB_PWD" -e "CREATE DATABASE IF NOT EXISTS ${DB_NAME} DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + mysql -u root -p"$DB_PWD" "$DB_NAME" < sql/mysql/init.sql + echo "MySQL 初始化完成: $DB_NAME" + ;; + 2) + read -sp "PostgreSQL 密码: " DB_PWD; echo + read -p "数据库名 [seeyon_fs]: " DB_NAME; DB_NAME=${DB_NAME:-seeyon_fs} + export PGPASSWORD="$DB_PWD" + psql -U postgres -c "CREATE DATABASE $DB_NAME;" 2>/dev/null || true + psql -U postgres -d "$DB_NAME" -f sql/postgres/init.sql + unset PGPASSWORD + echo "PostgreSQL 初始化完成: $DB_NAME" + ;; + 3) + read -sp "SQL Server 密码: " DB_PWD; echo + read -p "数据库名 [seeyon_fs]: " DB_NAME; DB_NAME=${DB_NAME:-seeyon_fs} + sqlcmd -S localhost -U sa -P "$DB_PWD" -Q "CREATE DATABASE [$DB_NAME]" 2>/dev/null || true + sqlcmd -S localhost -U sa -P "$DB_PWD" -d "$DB_NAME" -i sql/sqlserver/init.sql + echo "SQL Server 初始化完成: $DB_NAME" + ;; + *) + echo "无效选择" + exit 1 + ;; +esac +DBEOF chmod +x "$DEPLOY_DIR/init-db.sh" -# 生成systemd服务文件 -echo "[6/6] 生成systemd服务..." -cat > "$DEPLOY_DIR/filesystem.service" << EOF +# systemd服务 +cat > "$DEPLOY_DIR/filesystem.service" < Base URL: `http://localhost:8080/api` > -> 认证方式: `Authorization: Bearer ` +> 认证方式: 请求头 `token: ` > > 统一响应格式: `{ "code": 0, "message": "success", "data": ... }` +> +> ID 类型: 所有 `id`、`folder_id`、`parent_id`、`file_id` 字段均为**字符串类型**(雪花算法生成的64位整数) --- ## 1. 认证 (Auth) -### 1.1 用户登录 +### 1.1 用户登录(密码) ``` POST /api/auth/login ``` @@ -26,11 +28,27 @@ POST /api/auth/login "token": "eyJhbGci...", "username": "admin", "nickname": "管理员", - "role": "admin" + "role": "admin", + "must_change_pwd": false } } ``` +### 1.2 应用登录(用户名+appId) +``` +POST /api/auth/app-login +``` +**请求体:** +```json +{ "username": "admin", "app_id": "your_app_id" } +``` +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| username | string | 是 | 用户名 | +| app_id | string | 是 | 开放平台应用ID, 需在后台创建应用并绑定用户 | + +> 校验逻辑: appId 存在且启用 → 用户已绑定该应用 → 生成 Token + ### 1.2 用户注册 ``` POST /api/auth/register @@ -43,13 +61,13 @@ POST /api/auth/register ### 1.3 获取当前用户信息 ``` GET /api/auth/profile -Authorization: Bearer +token: ``` ### 1.4 获取当前用户权限 ``` GET /api/auth/permissions -Authorization: Bearer +token: ``` **响应:** ```json @@ -70,13 +88,13 @@ Authorization: Bearer ``` POST /api/file/upload?folder_id=1&storage_policy_id=1 Content-Type: multipart/form-data -Authorization: Bearer +token: ``` **参数:** | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | file | File | 是 | 文件内容 | -| folder_id | int | 否 | 目标文件夹ID, 0=根目录 | +| folder_id | string | 否 | 目标文件夹ID, "0"=根目录 | | storage_policy_id | int | 否 | 存储策略ID, 0=使用默认策略 | **响应:** @@ -84,10 +102,10 @@ Authorization: Bearer { "code": 0, "data": { - "id": 1, + "id": "170406720000000001", "name": "test.txt", "extension": "txt", - "folder_id": 1, + "folder_id": "0", "size": 1024, "storage_key": "2026/07/02/ab/abcdef123456.txt", "storage_policy_id": 1, @@ -101,7 +119,7 @@ Authorization: Bearer **Step 1: 初始化上传** ``` POST /api/file/upload/init -Authorization: Bearer +token: ``` **请求体:** ```json @@ -109,7 +127,7 @@ Authorization: Bearer "file_name": "large_video.mp4", "file_size": 104857600, "md5": "d41d8cd98f00b204e9800998ecf8427e", - "folder_id": 1, + "folder_id": "0", "policy_id": 1 } ``` @@ -131,7 +149,7 @@ Authorization: Bearer ``` PUT /api/file/upload/chunk/:uploadId/:index Content-Type: multipart/form-data -Authorization: Bearer +token: X-Checksum: sha256_of_chunk ``` **表单字段:** `chunk` = 分片二进制数据 @@ -139,7 +157,7 @@ X-Checksum: sha256_of_chunk **Step 3: 查询进度(断点续传)** ``` GET /api/file/upload/progress/:uploadId -Authorization: Bearer +token: ``` **响应:** ```json @@ -159,25 +177,25 @@ Authorization: Bearer **Step 4: 合并完成** ``` POST /api/file/upload/merge/:uploadId -Authorization: Bearer +token: ``` **Step 5: 取消上传** ``` DELETE /api/file/upload/:uploadId -Authorization: Bearer +token: ``` ### 2.3 获取文件信息 ``` GET /api/file/:id -Authorization: Bearer +token: ``` ### 2.3 根据 fileKey 查询文件信息 ``` GET /api/file/info?key=2026/07/02/ab/abcdef123456.txt -Authorization: Bearer +token: ``` **参数:** | 参数 | 类型 | 必填 | 说明 | @@ -187,14 +205,14 @@ Authorization: Bearer ### 2.4 直接下载文件流 ``` GET /api/file/:id/download -Authorization: Bearer +token: ``` **响应:** 文件二进制流, Content-Disposition: attachment ### 2.5 获取下载链接 ``` GET /api/file/:id/download-url -Authorization: Bearer +token: ``` **响应:** ```json @@ -210,14 +228,14 @@ Authorization: Bearer ### 2.6 预览文件流 ``` GET /api/file/:id/preview -Authorization: Bearer +token: ``` **响应:** 文件二进制流, Content-Disposition: inline ### 2.7 获取预览链接 ``` GET /api/file/:id/preview-url -Authorization: Bearer +token: ``` **响应:** ```json @@ -232,7 +250,7 @@ Authorization: Bearer ### 2.8 重命名文件 ``` PUT /api/file/:id/rename -Authorization: Bearer +token: ``` **请求体:** ```json @@ -242,7 +260,7 @@ Authorization: Bearer ### 2.9 移动文件 ``` POST /api/file/:id/move -Authorization: Bearer +token: ``` **请求体:** ```json @@ -252,33 +270,138 @@ Authorization: Bearer ### 2.10 删除文件(移入回收站) ``` DELETE /api/file/:id -Authorization: Bearer +token: ``` ### 2.11 恢复文件 ``` POST /api/file/:id/restore -Authorization: Bearer +token: ``` ### 2.12 永久删除文件 ``` DELETE /api/file/:id/permanent -Authorization: Bearer +token: ``` ### 2.13 搜索文件 ``` GET /api/file/search?keyword=报告 -Authorization: Bearer +GET /api/file/search?type=image +token: ``` +**参数:** +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| keyword | string | 与type二选一 | 搜索关键词, 支持 `\|` 分隔多关键词(OR匹配) | +| type | string | 与keyword二选一 | 按文件类型搜索 | + +**支持的 type 值:** +| type | 匹配扩展名 | +|------|-----------| +| image | jpg, jpeg, png, gif, bmp, webp, svg, ico | +| video | mp4, avi, mov, wmv, flv, mkv, webm | +| audio | mp3, wav, ogg, aac, flac, wma | +| document | pdf, doc, docx, xls, xlsx, ppt, pptx, txt, md, csv | +| archive | zip, rar, 7z, tar, gz, bz2 | +| code | js, ts, py, go, java, c, cpp, h, css, html, sql, json, xml, yaml, yml | + +**响应:** +```json +{ + "code": 0, + "data": [ + { + "id": "170406720000000007", + "name": "pic.jpg", + "extension": "jpg", + "folder_id": "0", + "size": 6770, + "storage_key": "2026/07/03/be/be2f5ef653f22a3f.jpg", + "storage_policy_id": 8, + "mime_type": "image/jpeg", + "is_favorite": 0, + "download_count": 2, + "version": 1, + "download_url": "/api/file/170406720000000007/download", + "preview_url": "/api/file/170406720000000007/preview", + "created_at": "2026-07-03T18:30:50.496+08:00", + "updated_at": "2026-07-03T22:19:14.916+08:00" + } + ] +} +``` +> 管理员可搜到所有文件, 非管理员只能搜到自己创建的 + 被授权的。最多返回200条。 ### 2.14 获取回收站列表 ``` GET /api/file/trash -Authorization: Bearer +token: ``` +### 2.15 查看文件授权列表 +``` +GET /api/file/:id/grant +token: +``` +**响应:** +```json +{ + "code": 0, + "data": [ + { + "id": 1, + "file_id": "170406720000000005", + "user_id": 3, + "user_name": "张三", + "perm_level": "read", + "grant_by": 1, + "grant_by_name": "管理员", + "created_at": "2026-07-03 12:00:00" + }, + { + "id": 2, + "file_id": "170406720000000005", + "role_id": 5, + "role_name": "内部员工", + "perm_level": "write", + "grant_by": 1, + "grant_by_name": "管理员", + "created_at": "2026-07-03 12:00:00" + } + ] +} +``` + +### 2.16 授权文件权限 +``` +POST /api/file/:id/grant +token: +``` +**请求体(授权给用户):** +```json +{ "user_id": 3, "perm_level": "read" } +``` +**请求体(授权给角色):** +```json +{ "role_id": 5, "perm_level": "write" } +``` +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| user_id | int | 与role_id二选一 | 授权给指定用户 | +| role_id | int | 与user_id二选一 | 授权给指定角色 | +| perm_level | string | 是 | read / write / admin | + +> 需要 `file:grant` 权限。同一文件+同一用户/角色重复授权会更新权限级别。 + +### 2.17 撤销文件权限 +``` +DELETE /api/file/:id/grant/:permId +token: +``` +> 需要 `file:grant` 权限。只能撤销自己授权的或管理员可撤销任意。 + --- ## 3. 文件夹管理 (Folder) @@ -286,52 +409,82 @@ Authorization: Bearer ### 3.1 创建文件夹 ``` POST /api/folder -Authorization: Bearer +token: ``` **请求体:** ```json -{ "name": "项目文档", "parent_id": 0 } +{ "name": "项目文档", "parent_id": "0", "biz_id": "ext_001" } ``` +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | string | 是 | 文件夹名称 | +| parent_id | string | 否 | 父文件夹ID, "0"=根目录 | +| biz_id | string | 否 | 业务ID, 用于外部系统关联 | + **响应:** ```json { "code": 0, - "data": { "id": 1, "name": "项目文档", "parent_id": 0, "path": "/1/" } + "data": { "id": "170406720000000001", "biz_id": "ext_001", "name": "项目文档", "parent_id": "0", "path": "/170406720000000001/" } } ``` -### 3.2 获取文件夹内容(文件+子文件夹) +### 3.2 获取文件夹内容(支持分页) ``` -GET /api/folder/:id/children -Authorization: Bearer +GET /api/folder/:id/children?page=1&page_size=50 +token: ``` > `:id` 为 `root` 时获取根目录 +**参数:** +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| page | int | 否 | 页码, 默认1 | +| page_size | int | 否 | 每页大小, 默认50, 最大200 | + **响应:** ```json { "code": 0, "data": { - "folders": [{ "id": 2, "name": "子文件夹", "parent_id": 1 }], - "files": [{ "id": 1, "name": "test.txt", "size": 1024 }] + "folders": [{ "id": "170406720000000002", "name": "子文件夹", "parent_id": "170406720000000001" }], + "files": [{ "id": "170406720000000003", "name": "test.txt", "size": 1024, "storage_key": "2026/...", "storage_policy_id": 8 }], + "total_files": 25, + "total_folders": 3, + "page": 1, + "page_size": 50 } } ``` +> 文件夹不分页(通常数量较少), 仅文件分页 -### 3.3 重命名文件夹 +### 3.3 根据业务ID获取文件夹 +``` +GET /api/folder/by-bizid/:bizid +token: +``` +**响应:** +```json +{ + "code": 0, + "data": { "id": "170406720000000001", "biz_id": "ext_001", "name": "项目文档", "parent_id": "0", "path": "/170406720000000001/" } +} +``` + +### 3.4 重命名文件夹 ``` PUT /api/folder/:id/rename -Authorization: Bearer +token: ``` **请求体:** ```json { "new_name": "新名称" } ``` -### 3.4 删除文件夹 +### 3.5 删除文件夹 ``` DELETE /api/folder/:id -Authorization: Bearer +token: ``` --- @@ -341,12 +494,12 @@ Authorization: Bearer ### 4.1 创建分享链接 ``` POST /api/share -Authorization: Bearer +token: ``` **请求体:** ```json { - "file_id": 1, + "file_id": "170406720000000001", "password": "123456", "expire_hours": 24, "max_download": 10 @@ -357,7 +510,7 @@ Authorization: Bearer { "code": 0, "data": { - "id": 1, + "id": "170406720000000010", "share_code": "a1b2c3d4e5f6g7h8", "share_url": "/s/a1b2c3d4e5f6g7h8", "has_password": true, @@ -371,13 +524,13 @@ Authorization: Bearer ### 4.2 获取我的分享列表 ``` GET /api/share -Authorization: Bearer +token: ``` ### 4.3 取消分享 ``` DELETE /api/share/:id -Authorization: Bearer +token: ``` ### 4.4 获取分享信息(公开) @@ -474,13 +627,13 @@ POST /api/sso/callback ### 5.5 获取SSO提供商详情(管理员) ``` GET /api/admin/sso/providers/:id -Authorization: Bearer +token: ``` ### 5.6 创建SSO提供商(管理员) ``` POST /api/admin/sso/providers -Authorization: Bearer +token: ``` **请求体:** ```json @@ -514,14 +667,14 @@ Authorization: Bearer ### 5.7 更新SSO提供商(管理员) ``` PUT /api/admin/sso/providers/:id -Authorization: Bearer +token: ``` > 请求体同创建, 字段可选(只传需要修改的字段) ### 5.8 启用/禁用SSO提供商(管理员) ``` PUT /api/admin/sso/providers/:id/toggle -Authorization: Bearer +token: ``` **请求体:** ```json @@ -531,7 +684,7 @@ Authorization: Bearer ### 5.9 删除SSO提供商(管理员) ``` DELETE /api/admin/sso/providers/:id -Authorization: Bearer +token: ``` --- @@ -541,13 +694,13 @@ Authorization: Bearer ### 6.1 用户列表 ``` GET /api/admin/users?page=1&size=20 -Authorization: Bearer +token: ``` ### 6.2 创建用户 ``` POST /api/admin/users -Authorization: Bearer +token: ``` **请求体:** ```json @@ -564,19 +717,19 @@ Authorization: Bearer ### 6.3 更新用户 ``` PUT /api/admin/users/:id -Authorization: Bearer +token: ``` ### 6.4 删除用户 ``` DELETE /api/admin/users/:id -Authorization: Bearer +token: ``` ### 6.5 重置密码 ``` PUT /api/admin/users/:id/password -Authorization: Bearer +token: ``` **请求体:** ```json @@ -586,7 +739,7 @@ Authorization: Bearer ### 6.6 设置用户角色 ``` PUT /api/admin/users/:id/roles -Authorization: Bearer +token: ``` **请求体:** ```json @@ -600,13 +753,13 @@ Authorization: Bearer ### 7.1 角色列表 ``` GET /api/admin/roles -Authorization: Bearer +token: ``` ### 7.2 创建角色 ``` POST /api/admin/roles -Authorization: Bearer +token: ``` **请求体:** ```json @@ -616,19 +769,19 @@ Authorization: Bearer ### 7.3 更新角色 ``` PUT /api/admin/roles/:id -Authorization: Bearer +token: ``` ### 7.4 删除角色 ``` DELETE /api/admin/roles/:id -Authorization: Bearer +token: ``` ### 7.5 设置角色权限 ``` PUT /api/admin/roles/:id/permissions -Authorization: Bearer +token: ``` **请求体:** ```json @@ -638,13 +791,13 @@ Authorization: Bearer ### 7.6 权限列表 ``` GET /api/admin/permissions -Authorization: Bearer +token: ``` ### 7.7 创建权限 ``` POST /api/admin/permissions -Authorization: Bearer +token: ``` **请求体:** ```json @@ -654,7 +807,7 @@ Authorization: Bearer ### 7.8 删除权限 ``` DELETE /api/admin/permissions/:id -Authorization: Bearer +token: ``` --- @@ -664,13 +817,13 @@ Authorization: Bearer ### 8.1 存储策略列表 ``` GET /api/admin/storage-policies -Authorization: Bearer +token: ``` ### 8.2 创建存储策略 ``` POST /api/admin/storage-policies -Authorization: Bearer +token: ``` **请求体 (本地存储):** ```json @@ -694,13 +847,13 @@ Authorization: Bearer ### 8.3 更新存储策略 ``` PUT /api/admin/storage-policies/:id -Authorization: Bearer +token: ``` ### 8.4 删除存储策略 ``` DELETE /api/admin/storage-policies/:id -Authorization: Bearer +token: ``` --- @@ -710,7 +863,7 @@ Authorization: Bearer ### 9.1 部门列表(树形) ``` GET /api/admin/departments -Authorization: Bearer +token: ``` **响应:** ```json @@ -731,7 +884,7 @@ Authorization: Bearer ### 9.2 创建部门 ``` POST /api/admin/departments -Authorization: Bearer +token: ``` **请求体:** ```json @@ -741,13 +894,13 @@ Authorization: Bearer ### 9.3 更新部门 ``` PUT /api/admin/departments/:id -Authorization: Bearer +token: ``` ### 9.4 删除部门 ``` DELETE /api/admin/departments/:id -Authorization: Bearer +token: ``` --- @@ -763,7 +916,7 @@ GET /health ### 10.2 获取CDN配置(管理员) ``` GET /api/admin/settings/cdn -Authorization: Bearer +token: ``` **响应:** ```json @@ -780,7 +933,7 @@ Authorization: Bearer ### 10.3 更新CDN配置(管理员) ``` PUT /api/admin/settings/cdn -Authorization: Bearer +token: ``` **请求体:** ```json @@ -808,6 +961,193 @@ Authorization: Bearer --- +## 11. 监控与日志 (Admin) + +### 11.1 流量统计 +``` +GET /api/admin/monitor/traffic +token: +``` + +### 11.2 最近请求记录 +``` +GET /api/admin/monitor/requests?limit=50 +token: +``` + +### 11.3 存储统计(按策略分组) +``` +GET /api/admin/monitor/storage +token: +``` +**响应:** +```json +{ + "code": 0, + "data": { + "policies": [ + { "policy_id": 8, "policy_name": "热存储", "file_count": 10, "total_size": 52428800 } + ], + "global": { + "total_files": 25, + "total_size": 104857600, + "total_folders": 8, + "total_users": 5 + } + } +} +``` + +### 11.4 系统运行日志 +``` +GET /api/admin/monitor/logs?limit=200 +token: +``` +**响应:** +```json +{ + "code": 0, + "data": { + "total": 156, + "entries": [ + { "time": "2026-07-03T22:33:09+08:00", "level": "INFO", "message": "数据库连接成功\n" }, + { "time": "2026-07-03T22:33:09+08:00", "level": "INFO", "message": "存储引擎初始化完成, 已加载 5 个策略\n" } + ] + } +} +``` +> 日志级别: INFO / WARN / ERROR + +### 11.5 操作日志列表 +``` +GET /api/admin/logs?page=1&size=20 +token: +``` + +### 11.6 操作日志统计 +``` +GET /api/admin/logs/stats +token: +``` + +### 11.7 清理操作日志 +``` +POST /api/admin/logs/clean +token: +``` + +--- + +## 12. IP黑名单管理 (Admin) + +### 12.1 获取黑名单 +``` +GET /api/admin/blacklist +token: +``` + +### 12.2 设置黑名单(全量替换) +``` +PUT /api/admin/blacklist +token: +``` +**请求体:** +```json +{ "enabled": true, "ips": ["192.168.1.100", "10.0.0.5"] } +``` + +### 12.3 添加封禁IP +``` +POST /api/admin/blacklist +token: +``` +**请求体:** +```json +{ "ip": "192.168.1.100" } +``` + +### 12.4 移除封禁IP +``` +DELETE /api/admin/blacklist/:ip +token: +``` + +--- + +## 13. 备份管理 (Admin) + +### 13.1 备份策略列表 +``` +GET /api/admin/backup/policies +token: +``` + +### 13.2 创建备份策略 +``` +POST /api/admin/backup/policies +token: +``` + +### 13.3 执行备份 +``` +POST /api/admin/backup/execute/:id +token: +``` + +### 13.4 备份日志 +``` +GET /api/admin/backup/logs +token: +``` + +### 13.5 恢复备份 +``` +POST /api/admin/backup/restore/:logId +token: +``` + +--- + +## 14. 存储策略分配 (Admin) + +### 14.1 分配列表 +``` +GET /api/admin/storage-assignments +token: +``` + +### 14.2 为角色分配存储策略 +``` +POST /api/admin/storage-assignments/role +token: +``` +**请求体:** +```json +{ "role_id": 5, "policy_id": 8, "storage_quota": 53687091200, "priority": 10 } +``` + +### 14.3 为用户分配存储策略 +``` +POST /api/admin/storage-assignments/user +token: +``` +**请求体:** +```json +{ "user_id": 3, "policy_id": 8, "storage_quota": 10737418240 } +``` + +### 14.4 设置默认存储策略 +``` +POST /api/admin/storage-assignments/default +token: +``` +**请求体:** +```json +{ "policy_id": 8 } +``` + +--- + ## 权限标识一览 | 权限标识 | 说明 | @@ -817,18 +1157,43 @@ Authorization: Bearer | file:update | 修改文件 | | file:delete | 删除文件 | | file:upload | 上传文件 | +| file:download | 下载文件 | +| file:grant | 授权文件权限(给其他用户/角色) | | share:create | 创建分享 | | share:manage | 管理分享 | +| webdav:access | WebDAV访问 | | user:manage | 用户管理 | | role:manage | 角色管理 | | sso:manage | SSO管理 | | system:manage | 系统管理 | | * | 所有权限(超级管理员) | +### 默认角色权限 + +| 角色 | 权限 | +|------|------| +| admin | *(全部) | +| staff | file:create, file:read, file:update, file:delete, file:upload, file:download, file:grant, share:create, webdav:access | +| user | file:create, file:read, file:update, file:upload, file:download, share:create | +| guest | file:read | + +### 文件可见性规则 + +| 角色 | 可见范围 | +|------|----------| +| admin | 所有文件 | +| 其他角色 | 自己创建的 + fs_file_permission 中被授权的 | + +### 文件操作权限规则 + +文件操作(删除/重命名/移动/下载/授权等)需要同时满足: +1. 用户角色拥有对应权限码(如 `file:delete`) +2. 用户有权操作该具体文件(owner / admin / fs_file_permission 被授权) + ### 2.17 统一文件访问(自动选择模式) ``` GET /api/file/:id/access -Authorization: Bearer +token: ``` > 根据存储策略的 `access_mode` 自动选择: > - `proxy`: 中转模式, 直接返回文件流 @@ -838,7 +1203,7 @@ Authorization: Bearer ### 2.18 获取文件访问URL ``` GET /api/file/:id/access-url -Authorization: Bearer +token: ``` **响应:** ```json diff --git a/server/dto/admin_dto.go b/server/dto/admin_dto.go index 8eaa407..68038da 100644 --- a/server/dto/admin_dto.go +++ b/server/dto/admin_dto.go @@ -107,12 +107,14 @@ type StorageAssignRequest struct { // UserStorageInfoVO 用户存储信息 type UserStorageInfoVO struct { - PolicyID uint64 `json:"policy_id"` - PolicyName string `json:"policy_name"` - PolicyType string `json:"policy_type"` - StorageQuota int64 `json:"storage_quota"` - UsedStorage int64 `json:"used_storage"` - UsedPercent float64 `json:"used_percent"` + PolicyID uint64 `json:"policy_id"` + PolicyName string `json:"policy_name"` + PolicyType string `json:"policy_type"` + StorageQuota int64 `json:"storage_quota"` + UsedStorage int64 `json:"used_storage"` + UsedPercent float64 `json:"used_percent"` + MaxFileSize int64 `json:"max_file_size"` // 单文件大小上限(字节) + ChunkThreshold int64 `json:"chunk_threshold"` // 分片上传阈值(字节) } // ========== 开放平台 ========== diff --git a/server/dto/auth_dto.go b/server/dto/auth_dto.go index ae07354..5718799 100644 --- a/server/dto/auth_dto.go +++ b/server/dto/auth_dto.go @@ -7,6 +7,12 @@ type LoginRequest struct { Password string `json:"password" binding:"required"` // 密码, 必填 } +// AppLoginRequest 应用登录请求(用户名+appId) +type AppLoginRequest struct { + Username string `json:"username" binding:"required"` // 用户名, 必填 + AppID string `json:"app_id" binding:"required"` // 开放平台应用ID, 必填 +} + // RegisterRequest 注册请求 type RegisterRequest struct { Username string `json:"username" binding:"required,min=3,max=64"` // 用户名, 3-64字符 diff --git a/server/dto/file_dto.go b/server/dto/file_dto.go index 5ff7a91..7ae0065 100644 --- a/server/dto/file_dto.go +++ b/server/dto/file_dto.go @@ -4,10 +4,12 @@ import "time" // FileVO 文件视图对象 - 返回给前端 type FileVO struct { - ID uint64 `json:"id"` + ID StringID `json:"id"` Name string `json:"name"` Extension string `json:"extension"` - FolderID uint64 `json:"folder_id"` + FolderID StringID `json:"folder_id"` + OwnerID StringID `json:"owner_id"` + OwnerName string `json:"owner_name,omitempty"` Size int64 `json:"size"` StorageKey string `json:"storage_key"` StoragePolicyID uint64 `json:"storage_policy_id"` @@ -23,25 +25,33 @@ type FileVO struct { // FolderVO 文件夹视图对象 type FolderVO struct { - ID uint64 `json:"id"` + ID StringID `json:"id"` + BizID string `json:"biz_id,omitempty"` Name string `json:"name"` - ParentID uint64 `json:"parent_id"` + ParentID StringID `json:"parent_id"` + OwnerID StringID `json:"owner_id"` + OwnerName string `json:"owner_name,omitempty"` Path string `json:"path"` Version int `json:"version"` // 乐观锁版本号 CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } -// FileListVO 文件列表响应 - 包含文件和文件夹 +// FileListVO 文件列表响应 - 包含文件和文件夹(分页) type FileListVO struct { - Folders []FolderVO `json:"folders"` // 文件夹列表 - Files []FileVO `json:"files"` // 文件列表 + Folders []FolderVO `json:"folders"` // 文件夹列表(不分页) + Files []FileVO `json:"files"` // 文件列表(当前页) + TotalFiles int64 `json:"total_files"` // 文件总数 + TotalFolders int64 `json:"total_folders"` // 文件夹总数 + Page int `json:"page"` // 当前页码 + PageSize int `json:"page_size"` // 每页大小 } // CreateFolderRequest 创建文件夹请求 type CreateFolderRequest struct { - Name string `json:"name" binding:"required,max=256"` // 文件夹名称 - ParentID uint64 `json:"parent_id"` // 父文件夹ID, 0=根目录 + BizID string `json:"biz_id"` // 业务ID(可选, 用于外部系统关联) + Name string `json:"name" binding:"required,max=256"` // 文件夹名称 + ParentID StringID `json:"parent_id"` // 父文件夹ID, "0"=根目录 } // RenameRequest 重命名请求 @@ -52,22 +62,43 @@ type RenameRequest struct { // MoveRequest 移动请求 type MoveRequest struct { - TargetFolderID uint64 `json:"target_folder_id" binding:"required"` // 目标文件夹ID + TargetFolderID StringID `json:"target_folder_id" binding:"required"` // 目标文件夹ID +} + +// GrantPermissionRequest 授权文件权限请求 +type GrantPermissionRequest struct { + UserID StringID `json:"user_id"` // 授权给用户(与role_id二选一) + RoleID StringID `json:"role_id"` // 授权给角色(与user_id二选一) + PermLevel string `json:"perm_level" binding:"required"` // read / write / admin +} + +// FilePermissionVO 文件权限视图 +type FilePermissionVO struct { + ID uint64 `json:"id"` + FileID uint64 `json:"file_id"` + UserID uint64 `json:"user_id,omitempty"` + RoleID uint64 `json:"role_id,omitempty"` + UserName string `json:"user_name,omitempty"` + RoleName string `json:"role_name,omitempty"` + PermLevel string `json:"perm_level"` + GrantBy uint64 `json:"grant_by"` + GrantByName string `json:"grant_by_name,omitempty"` + CreatedAt string `json:"created_at"` } // ShareCreateRequest 创建分享请求 type ShareCreateRequest struct { - FileID uint64 `json:"file_id" binding:"required"` - Password string `json:"password"` - ExpireHours int `json:"expire_hours"` - MaxDownload int `json:"max_download"` - AllowPreview int `json:"allow_preview"` // 1=允许 0=禁止, 默认1 - AllowDownload int `json:"allow_download"` // 1=允许 0=禁止, 默认1 + FileID StringID `json:"file_id" binding:"required"` + Password string `json:"password"` + ExpireHours int `json:"expire_hours"` + MaxDownload int `json:"max_download"` + AllowPreview int `json:"allow_preview"` // 1=允许 0=禁止, 默认1 + AllowDownload int `json:"allow_download"` // 1=允许 0=禁止, 默认1 } // ShareVO 分享视图对象 type ShareVO struct { - ID uint64 `json:"id"` + ID StringID `json:"id"` ShareCode string `json:"share_code"` ShareURL string `json:"share_url"` HasPassword bool `json:"has_password"` @@ -96,31 +127,84 @@ type ShareFileVO struct { CreatedAt time.Time `json:"created_at"` } +// ========== 对外API ========== + +// OpenAPIListResult 分页查询结果(树状结构) +type OpenAPIListResult struct { + FolderID uint64 `json:"folder_id"` + TotalFiles int64 `json:"total_files"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Folders []OpenAPIFolderVO `json:"folders"` + Files []OpenAPIFileVO `json:"files"` +} + +// OpenAPISearchResult 搜索结果(树状结构) +type OpenAPISearchResult struct { + Keyword string `json:"keyword"` + TotalFiles int64 `json:"total_files"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Folders []OpenAPIFolderVO `json:"folders"` + Files []OpenAPIFileVO `json:"files"` +} + +// OpenAPIFolderVO 文件夹视图(对外) +type OpenAPIFolderVO struct { + ID StringID `json:"id"` + Name string `json:"name"` + ParentID StringID `json:"parent_id"` + ChildCount int64 `json:"child_count,omitempty"` + CreatedAt string `json:"created_at"` +} + +// OpenAPIFileVO 文件视图(对外) +type OpenAPIFileVO struct { + ID StringID `json:"id"` + Name string `json:"name"` + Extension string `json:"extension"` + Size int64 `json:"size"` + MD5 string `json:"md5,omitempty"` + MimeType string `json:"mime_type"` + DownloadURL string `json:"download_url"` + CreatedAt string `json:"created_at"` +} + // ========== 分片上传 ========== // ChunkUploadInitRequest 初始化分片上传请求 type ChunkUploadInitRequest struct { FileName string `json:"file_name" binding:"required"` FileSize int64 `json:"file_size" binding:"required"` - MD5 string `json:"md5"` // 客户端计算的MD5(可选, 用于秒传) - FolderID uint64 `json:"folder_id"` + MD5 string `json:"md5"` // 客户端计算的MD5(可选, 用于秒传) + FolderID StringID `json:"folder_id"` PolicyID uint64 `json:"policy_id"` // 存储策略ID } // ChunkUploadInitVO 初始化分片上传响应 type ChunkUploadInitVO struct { - UploadID string `json:"upload_id"` // 上传会话ID - Instant bool `json:"instant"` // 是否秒传 - FileID uint64 `json:"file_id"` // 秒传时返回文件ID - ChunkSize int64 `json:"chunk_size"` // 分片大小 - Total int `json:"total"` // 总分片数 + UploadID string `json:"upload_id"` + Instant bool `json:"instant"` + FileID uint64 `json:"file_id"` + ChunkSize int64 `json:"chunk_size"` + Total int `json:"total"` + Mode string `json:"mode"` // local=服务端中转, minio=客户端直传 + UploadURL string `json:"upload_url"` // MinIO模式: 分片上传基础URL + Parts []ChunkPresignURL `json:"parts,omitempty"` // MinIO模式: 各分片预签名URL +} + +// ChunkPresignURL 分片预签名URL +type ChunkPresignURL struct { + Index int `json:"index"` + URL string `json:"url"` } // ChunkUploadChunkVO 单分片上传响应 type ChunkUploadChunkVO struct { Index int `json:"index"` Size int64 `json:"size"` - Status string `json:"status"` // ok / exists + ETag string `json:"etag,omitempty"` // MinIO返回的ETag + Status string `json:"status"` // ok / exists } // ChunkUploadProgressVO 上传进度响应 diff --git a/server/dto/id_string.go b/server/dto/id_string.go new file mode 100644 index 0000000..696dfc7 --- /dev/null +++ b/server/dto/id_string.go @@ -0,0 +1,26 @@ +package dto + +import ( + "fmt" + "strconv" +) + +// StringID 自定义ID类型, JSON序列化为字符串(兼容JS大整数) +type StringID uint64 + +func (id StringID) MarshalJSON() ([]byte, error) { + return []byte(`"` + strconv.FormatUint(uint64(id), 10) + `"`), nil +} + +func (id *StringID) UnmarshalJSON(data []byte) error { + s, err := strconv.Unquote(string(data)) + if err != nil { + return fmt.Errorf("StringID unmarshal: %w", err) + } + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return fmt.Errorf("StringID parse: %w", err) + } + *id = StringID(v) + return nil +} diff --git a/server/go.mod b/server/go.mod index 0226891..d6825ce 100644 --- a/server/go.mod +++ b/server/go.mod @@ -4,11 +4,11 @@ go 1.25.0 require ( github.com/gin-gonic/gin v1.9.1 - github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/spf13/viper v1.18.2 golang.org/x/crypto v0.51.0 gorm.io/driver/mysql v1.5.2 - gorm.io/gorm v1.25.5 + gorm.io/gorm v1.30.0 ) require ( @@ -24,8 +24,14 @@ require ( github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -35,6 +41,7 @@ require ( github.com/leodido/go-urn v1.2.4 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/microsoft/go-mssqldb v1.8.2 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/minio-go/v7 v7.2.1 // indirect @@ -61,9 +68,12 @@ require ( golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/ini.v1 v1.67.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/driver/sqlserver v1.6.3 // indirect ) diff --git a/server/go.sum b/server/go.sum index 584fa77..bcbdfee 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,3 +1,15 @@ +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= @@ -6,10 +18,13 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -34,18 +49,46 @@ github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -62,10 +105,14 @@ github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4O github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= @@ -74,6 +121,8 @@ github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APP github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microsoft/go-mssqldb v1.8.2 h1:236sewazvC8FvG6Dr3bszrVhMkAl4KYImryLkRMCd0I= +github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= @@ -87,17 +136,23 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= @@ -124,6 +179,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -131,6 +187,7 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -141,6 +198,7 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= @@ -154,26 +212,120 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= @@ -183,16 +335,28 @@ google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs= gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI= +gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/server/main.go b/server/main.go index b4322ac..c344a73 100644 --- a/server/main.go +++ b/server/main.go @@ -9,9 +9,14 @@ import ( "seeyon-filesystem/config" "seeyon-filesystem/model" "seeyon-filesystem/storage" + "seeyon-filesystem/utils" + "golang.org/x/crypto/bcrypt" ) func main() { + // 将日志输出接入环形缓冲区(同时保留控制台输出) + log.SetOutput(utils.LogBuf) + cfg, err := config.Load() if err != nil { log.Fatalf("加载配置失败: %v", err) @@ -21,9 +26,9 @@ func main() { log.Fatalf("初始化数据库失败: %v", err) } - if err := autoMigrate(); err != nil { - log.Fatalf("数据库迁移失败: %v", err) - } +// if err := autoMigrate(); err != nil { +// log.Fatalf("数据库迁移失败: %v", err) +// } initDefaultData() @@ -92,6 +97,7 @@ func initDefaultData() { {Code: "file:delete", Name: "删除文件", Resource: "file", Action: "delete"}, {Code: "file:upload", Name: "上传文件", Resource: "file", Action: "upload"}, {Code: "file:download", Name: "下载文件", Resource: "file", Action: "download"}, + {Code: "file:grant", Name: "授权文件权限", Resource: "file", Action: "grant"}, {Code: "share:create", Name: "创建分享", Resource: "share", Action: "create"}, {Code: "share:manage", Name: "管理分享", Resource: "share", Action: "manage"}, {Code: "webdav:access", Name: "WebDAV访问", Resource: "webdav", Action: "access"}, @@ -106,7 +112,7 @@ func initDefaultData() { // ========== 2. 四级角色 ========== roles := []model.Role{ - {Name: "super_admin", DisplayName: "超级管理员", Description: "全平台最高权限, 仅用于后台运维", IsSystem: 1}, + {Name: "admin", DisplayName: "超级管理员", Description: "全平台最高权限, 仅用于后台运维", IsSystem: 1}, {Name: "staff", DisplayName: "内部员工", Description: "团队内部成员, 可上传下载分享文件", IsSystem: 1}, {Name: "user", DisplayName: "普通用户", Description: "注册用户, 基础存储功能", IsSystem: 1}, {Name: "guest", DisplayName: "访客", Description: "仅可查看公开分享资源", IsSystem: 1}, @@ -117,8 +123,8 @@ func initDefaultData() { // 角色-权限分配 rolePermMap := map[string][]string{ - "super_admin": {"*"}, // 全部权限 - "staff": {"file:create", "file:read", "file:update", "file:delete", "file:upload", "file:download", "share:create", "webdav:access"}, + "admin": {"*"}, // 全部权限 + "staff": {"file:create", "file:read", "file:update", "file:delete", "file:upload", "file:download", "file:grant", "share:create", "webdav:access"}, "user": {"file:create", "file:read", "file:update", "file:upload", "file:download", "share:create"}, "guest": {"file:read"}, } @@ -190,7 +196,7 @@ func initDefaultData() { Priority int } assignDefs := []assignDef{ - {RoleName: "super_admin", PolicyType: "hot", Quota: 0, Priority: 10}, + {RoleName: "admin", PolicyType: "hot", Quota: 0, Priority: 10}, {RoleName: "staff", PolicyType: "hot", Quota: 53687091200, Priority: 10}, {RoleName: "user", PolicyType: "cold", Quota: 10737418240, Priority: 10}, } @@ -229,16 +235,66 @@ func initDefaultData() { } } - // ========== 5. 确保admin用户有super_admin角色 ========== - var adminUser model.User - if config.DB.Where("username = ?", "admin").First(&adminUser).Error == nil { - var superAdminRole model.Role - config.DB.Where("name = ?", "super_admin").First(&superAdminRole) - if superAdminRole.ID > 0 { - var ur model.UserRole - config.DB.Where("user_id = ? AND role_id = ?", adminUser.ID, superAdminRole.ID).FirstOrCreate(&ur) - } - } + // ========== 5. 创建默认管理员 ========== + var count int64 + config.DB.Model(&model.User{}).Where("username = ?", "admin").Count(&count) + + if count == 0 { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin123"), 10) + if err != nil { + log.Fatalf("密码加密失败: %v", err) + } + + admin := &model.User{ + Username: "admin", + Password: string(hashedPassword), + Status: 1, + } + + if err := config.DB.Create(admin).Error; err != nil { + log.Fatalf("创建 admin 失败: %v", err) + } + log.Println("✅ 默认管理员已创建: admin / admin123") + } else { + log.Println("⚠️ admin 用户已存在,跳过") + } + + + +// ========== 6. 确保admin用户有admin角色 ========== +var adminUser model.User +if config.DB.Where("username = ?", "admin").First(&adminUser).Error == nil { + var superAdminRole model.Role + err := config.DB.Where("name = ?", "admin").First(&superAdminRole).Error + if err == nil { // ✅ 用 Error 判断,找到了才继续 + var ur model.UserRole + if err := config.DB.Where("user_id = ? AND role_id = ?", adminUser.ID, superAdminRole.ID).First(&ur).Error; err != nil { + // 没找到这条关联记录,才创建 + config.DB.Create(&model.UserRole{UserID: adminUser.ID, RoleID: superAdminRole.ID}) + log.Println("✅ admin 已分配 admin 角色") + } else { + log.Println("⚠️ admin 已有 admin 角色,跳过") + } + } else { + log.Println("❌ 找不到 admin 角色") + } +} + + +// ========== 7. 给没有角色的用户补上默认角色(user) ========== +var userRole model.Role +if config.DB.Where("name = ?", "user").First(&userRole).Error == nil { + // ✅ 排除 admin + var usersWithoutRole []model.User + config.DB.Where("id NOT IN (SELECT user_id FROM fs_user_role) AND username != ?", "admin").Find(&usersWithoutRole) + + for _, u := range usersWithoutRole { + config.DB.Create(&model.UserRole{UserID: u.ID, RoleID: userRole.ID}) + } + if len(usersWithoutRole) > 0 { + log.Printf("已为 %d 个无角色用户分配默认角色", len(usersWithoutRole)) + } +} log.Println("默认数据初始化完成") } diff --git a/server/middleware/auth.go b/server/middleware/auth.go index e8f2374..2baf5cb 100644 --- a/server/middleware/auth.go +++ b/server/middleware/auth.go @@ -10,25 +10,32 @@ import ( ) // JWTAuth JWT认证中间件 -// 从请求头 Authorization: Bearer 中提取并验证JWT +// 从请求头 token 中提取并验证JWT // 验证通过后将用户信息存入上下文 func JWTAuth(secret string) gin.HandlerFunc { return func(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" { - utils.Unauthorized(c, "缺少Authorization请求头") + token := c.GetHeader("token") + if token == "" { + // 兼容查询参数 ?token=xxx (用于浏览器直接访问预览/下载链接) + token = c.Query("token") + } + if token == "" { + // 兼容 Authorization: Bearer xxx + authHeader := c.GetHeader("Authorization") + if authHeader != "" { + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 && parts[0] == "Bearer" { + token = parts[1] + } + } + } + if token == "" { + utils.Unauthorized(c, "缺少token请求头") c.Abort() return } - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || parts[0] != "Bearer" { - utils.Unauthorized(c, "Authorization格式错误, 应为: Bearer ") - c.Abort() - return - } - - claims, err := utils.ParseToken(parts[1], secret) + claims, err := utils.ParseToken(token, secret) if err != nil { utils.Unauthorized(c, "Token无效或已过期: "+err.Error()) c.Abort() diff --git a/server/model/file.go b/server/model/file.go index 369b5af..a3b09c7 100644 --- a/server/model/file.go +++ b/server/model/file.go @@ -4,7 +4,7 @@ import "time" // File 文件模型 type File struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + ID uint64 `json:"id" gorm:"primaryKey"` Name string `json:"name" gorm:"size:256;not null"` Extension string `json:"extension" gorm:"size:32"` FolderID uint64 `json:"folder_id" gorm:"index;not null"` diff --git a/server/model/folder.go b/server/model/folder.go index 84f467a..627126c 100644 --- a/server/model/folder.go +++ b/server/model/folder.go @@ -6,7 +6,8 @@ import "time" // 使用物化路径(materialized path)实现树形结构 // 例如: /0/1/5/ 表示根目录下ID=1的文件夹中的ID=5的文件夹 type Folder struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + ID uint64 `json:"id" gorm:"primaryKey"` + BizID string `json:"biz_id" gorm:"index;size:128"` // 业务ID, 用于外部系统关联 Name string `json:"name" gorm:"size:256;not null"` ParentID uint64 `json:"parent_id" gorm:"index;default:0"` // 0=根目录 OwnerID uint64 `json:"owner_id" gorm:"index;not null"` diff --git a/server/repository/file_repo.go b/server/repository/file_repo.go index a205ec3..a018ab5 100644 --- a/server/repository/file_repo.go +++ b/server/repository/file_repo.go @@ -3,6 +3,8 @@ package repository import ( "seeyon-filesystem/config" "seeyon-filesystem/model" + + "gorm.io/gorm" ) // FileRepository 文件数据访问 @@ -43,15 +45,49 @@ func (r *FileRepository) FindByMD5(md5 string) (*model.File, error) { func (r *FileRepository) ListByFolder(folderID uint64, userID uint64, roleIDs []uint64) ([]model.File, error) { var files []model.File - // 构建权限条件: 自己的 OR 直接授权 OR 角色授权 + query := r.buildListByFolderQuery(folderID, userID, roleIDs) + err := query.Order("f.extension ASC, f.name ASC").Find(&files).Error + return files, err +} + +// ListByFolderPaginated 分页查询文件夹下的文件列表(基于权限) +func (r *FileRepository) ListByFolderPaginated(folderID uint64, userID uint64, roleIDs []uint64, page, pageSize int) ([]model.File, int64, error) { + var files []model.File + var total int64 + + query := r.buildListByFolderQuery(folderID, userID, roleIDs) + + // 统计总数 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 分页查询 + offset := (page - 1) * pageSize + err := query.Order("f.extension ASC, f.name ASC").Offset(offset).Limit(pageSize).Find(&files).Error + return files, total, err +} + +// buildListByFolderQuery 构建文件夹文件查询(公共逻辑) +// 可见性: 管理员看全部 | 自己的 | 直接授权 | 角色授权 +func (r *FileRepository) buildListByFolderQuery(folderID uint64, userID uint64, roleIDs []uint64) *gorm.DB { + // 检查是否为管理员 + isAdmin := r.isAdmin(roleIDs) + + // 管理员: 无额外条件, 看所有文件 + if isAdmin { + query := config.DB.Table("fs_file f"). + Where("f.folder_id = ? AND f.status = 1", folderID) + return query + } + + // 非管理员: 自己的 + 直接授权 + 角色授权 permCond := "f.owner_id = ?" args := []interface{}{userID} - // 直接授权 permCond += " OR fp_user.user_id = ?" args = append(args, userID) - // 角色授权 if len(roleIDs) > 0 { permCond += " OR fp_role.role_id IN ?" args = append(args, roleIDs) @@ -65,11 +101,18 @@ func (r *FileRepository) ListByFolder(folderID uint64, userID uint64, roleIDs [] query = query.Joins("LEFT JOIN fs_file_permission fp_role ON fp_role.file_id = f.id AND fp_role.role_id IN ?", roleIDs) } - query = query.Where("f.folder_id = ? AND f.status = 1 AND ("+permCond+")", append([]interface{}{folderID}, args...)...). - Order("f.name ASC") + query = query.Where("f.folder_id = ? AND f.status = 1 AND ("+permCond+")", append([]interface{}{folderID}, args...)...) + return query +} - err := query.Find(&files).Error - return files, err +// isAdmin 检查用户角色中是否包含 admin +func (r *FileRepository) isAdmin(roleIDs []uint64) bool { + if len(roleIDs) == 0 { + return false + } + var count int64 + config.DB.Model(&model.Role{}).Where("id IN ? AND name = ?", roleIDs, "admin").Count(&count) + return count > 0 } // ListByOwner 查询用户的所有文件(仅自己的) @@ -84,24 +127,34 @@ func (r *FileRepository) ListByOwner(ownerID uint64) ([]model.File, error) { func (r *FileRepository) SearchByName(userID uint64, roleIDs []uint64, keyword string) ([]model.File, error) { var files []model.File - // 权限条件 - permCond := "f.owner_id = ? OR fp_user.user_id = ?" - args := []interface{}{userID, userID} - if len(roleIDs) > 0 { - permCond += " OR fp_role.role_id IN ?" - args = append(args, roleIDs) + isAdmin := r.isAdmin(roleIDs) + + var query *gorm.DB + + if isAdmin { + // 管理员: 搜所有文件 + query = config.DB.Table("fs_file f"). + Where("f.status = 1") + } else { + // 非管理员: 自己的 + 直接授权 + 角色授权 + permCond := "f.owner_id = ? OR fp_user.user_id = ?" + args := []interface{}{userID, userID} + if len(roleIDs) > 0 { + permCond += " OR fp_role.role_id IN ?" + args = append(args, roleIDs) + } + + query = config.DB.Table("fs_file f"). + Select("DISTINCT f.*"). + Joins("LEFT JOIN fs_file_permission fp_user ON fp_user.file_id = f.id AND fp_user.user_id = ?", userID) + + if len(roleIDs) > 0 { + query = query.Joins("LEFT JOIN fs_file_permission fp_role ON fp_role.file_id = f.id AND fp_role.role_id IN ?", roleIDs) + } + + query = query.Where("f.status = 1 AND ("+permCond+")", args...) } - query := config.DB.Table("fs_file f"). - Select("DISTINCT f.*"). - Joins("LEFT JOIN fs_file_permission fp_user ON fp_user.file_id = f.id AND fp_user.user_id = ?", userID) - - if len(roleIDs) > 0 { - query = query.Joins("LEFT JOIN fs_file_permission fp_role ON fp_role.file_id = f.id AND fp_role.role_id IN ?", roleIDs) - } - - query = query.Where("f.status = 1 AND ("+permCond+")", args...) - // 多关键词: 用|分隔, OR匹配 if keyword != "" { keywords := splitKeywords(keyword) diff --git a/server/repository/folder_repo.go b/server/repository/folder_repo.go index 4abcc29..00c3c3e 100644 --- a/server/repository/folder_repo.go +++ b/server/repository/folder_repo.go @@ -28,6 +28,17 @@ func (r *FolderRepository) FindByID(id uint64) (*model.Folder, error) { return &folder, nil } +// FindByBizID 根据业务ID查找文件夹 +func (r *FolderRepository) FindByBizID(bizID string, ownerID uint64) (*model.Folder, error) { + var folder model.Folder + err := config.DB.Where("biz_id = ? AND owner_id = ? AND status = 1", bizID, ownerID). + First(&folder).Error + if err != nil { + return nil, err + } + return &folder, nil +} + // ListByParent 查询子文件夹列表 func (r *FolderRepository) ListByParent(parentID uint64, ownerID uint64) ([]model.Folder, error) { var folders []model.Folder @@ -36,6 +47,32 @@ func (r *FolderRepository) ListByParent(parentID uint64, ownerID uint64) ([]mode return folders, err } +// CountByParent 统计子文件夹数量 +func (r *FolderRepository) CountByParent(parentID uint64, ownerID uint64) (int64, error) { + var count int64 + err := config.DB.Model(&model.Folder{}). + Where("parent_id = ? AND owner_id = ? AND status = 1", parentID, ownerID). + Count(&count).Error + return count, err +} + +// ListByParentAll 查询子文件夹列表(管理员模式, 不限owner) +func (r *FolderRepository) ListByParentAll(parentID uint64) ([]model.Folder, error) { + var folders []model.Folder + err := config.DB.Where("parent_id = ? AND status = 1", parentID). + Order("name ASC").Find(&folders).Error + return folders, err +} + +// CountByParentAll 统计子文件夹数量(管理员模式, 不限owner) +func (r *FolderRepository) CountByParentAll(parentID uint64) (int64, error) { + var count int64 + err := config.DB.Model(&model.Folder{}). + Where("parent_id = ? AND status = 1", parentID). + Count(&count).Error + return count, err +} + // FindByPath 根据路径查找文件夹 func (r *FolderRepository) FindByPath(path string, ownerID uint64) (*model.Folder, error) { var folder model.Folder diff --git a/server/service/admin_service.go b/server/service/admin_service.go index dbe2f49..f39281a 100644 --- a/server/service/admin_service.go +++ b/server/service/admin_service.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" + "seeyon-filesystem/config" "seeyon-filesystem/dto" "seeyon-filesystem/model" "seeyon-filesystem/repository" @@ -77,6 +78,12 @@ func (s *AdminService) CreateUser(req *dto.UserCreateRequest) error { // 分配角色 if len(req.RoleIDs) > 0 { s.userRoleRepo.SetUserRoles(user.ID, req.RoleIDs) + } else { + // 未指定角色时, 自动分配默认角色(user) + var defaultRole model.Role + if config.DB.Where("name = ?", "user").First(&defaultRole).Error == nil { + config.DB.Create(&model.UserRole{UserID: user.ID, RoleID: defaultRole.ID}) + } } return nil diff --git a/server/service/auth_service.go b/server/service/auth_service.go index f4cf6a0..30e6b90 100644 --- a/server/service/auth_service.go +++ b/server/service/auth_service.go @@ -25,8 +25,7 @@ func NewAuthService() *AuthService { } } -// Login 用户登录 -// 验证用户名密码, 生成JWT Token +// Login 用户登录(用户名+密码) func (s *AuthService) Login(req *dto.LoginRequest) (*dto.LoginResponse, error) { // 查找用户 user, err := s.userRepo.FindByUsername(req.Username) @@ -68,6 +67,64 @@ func (s *AuthService) Login(req *dto.LoginRequest) (*dto.LoginResponse, error) { }, nil } +// validateAppLogin 校验 appId 登录 +func (s *AuthService) validateAppLogin(appID string, userID uint64) error { + // 1. appId 是否存在且启用 + var app model.App + if err := config.DB.Where("app_id = ? AND status = 1", appID).First(&app).Error; err != nil { + return errors.New("应用ID无效或已禁用") + } + // 2. 用户是否已绑定该应用 + var count int64 + config.DB.Model(&model.AppUser{}).Where("app_id = ? AND user_id = ?", app.ID, userID).Count(&count) + if count == 0 { + return errors.New("用户未授权此应用") + } + return nil +} + +// AppLogin 应用登录(用户名+appId) +func (s *AuthService) AppLogin(req *dto.AppLoginRequest) (*dto.LoginResponse, error) { + // 查找用户 + user, err := s.userRepo.FindByUsername(req.Username) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("用户名或密码错误") + } + return nil, err + } + + // 检查用户状态 + if user.Status != 1 { + return nil, errors.New("账号已被禁用") + } + + // 校验 appId + if err := s.validateAppLogin(req.AppID, user.ID); err != nil { + return nil, err + } + + // 生成JWT Token + token, err := utils.GenerateToken( + user.ID, + user.Username, + user.Role, + config.AppConfig.JWT.Secret, + config.AppConfig.JWT.Expiration, + ) + if err != nil { + return nil, errors.New("生成Token失败") + } + + return &dto.LoginResponse{ + Token: token, + Username: user.Username, + Nickname: user.Nickname, + Role: user.Role, + MustChangePwd: user.MustChangePwd == 1, + }, nil +} + // Register 用户注册 func (s *AuthService) Register(req *dto.RegisterRequest) error { existing, _ := s.userRepo.FindByUsername(req.Username) @@ -89,7 +146,17 @@ func (s *AuthService) Register(req *dto.RegisterRequest) error { Status: 1, } - return s.userRepo.Create(user) + if err := s.userRepo.Create(user); err != nil { + return err + } + + // 分配默认角色(user), 使存储策略分配生效 + var role model.Role + if config.DB.Where("name = ?", "user").First(&role).Error == nil { + config.DB.Create(&model.UserRole{UserID: user.ID, RoleID: role.ID}) + } + + return nil } // ChangePassword 修改密码 diff --git a/server/service/backup_service.go b/server/service/backup_service.go index 4530ebe..e0af095 100644 --- a/server/service/backup_service.go +++ b/server/service/backup_service.go @@ -197,23 +197,25 @@ func (s *BackupService) doBackup(policy *model.BackupPolicy, log *model.BackupLo s.cleanOldBackups(policy) } -// backupDatabase 备份数据库 +// backupDatabase 备份数据库(根据数据库类型选择工具) func (s *BackupService) backupDatabase(tarWriter *tar.Writer, timestamp string) (int64, error) { dbCfg := config.AppConfig.Database - dumpFile := filepath.Join(os.TempDir(), fmt.Sprintf("db_dump_%s.sql", timestamp)) - mysqldumpPath := s.findTool("mysqldump", config.AppConfig.Backup.MysqlDumpPath) + var dumpFile string + var cmd *exec.Cmd + var err error - cmd := exec.Command(mysqldumpPath, - "-h", dbCfg.Host, - "-P", fmt.Sprintf("%d", dbCfg.Port), - "-u", dbCfg.Username, - "-p"+dbCfg.Password, - "--single-transaction", - "--routines", - "--triggers", - dbCfg.DBName, - ) + switch dbCfg.Type { + case "sqlserver": + dumpFile, cmd, err = s.buildSQLServerDump(dbCfg, timestamp) + case "postgres": + dumpFile, cmd, err = s.buildPostgresDump(dbCfg, timestamp) + default: + dumpFile, cmd, err = s.buildMySQLDump(dbCfg, timestamp) + } + if err != nil { + return 0, err + } outFile, err := os.Create(dumpFile) if err != nil { @@ -225,18 +227,16 @@ func (s *BackupService) backupDatabase(tarWriter *tar.Writer, timestamp string) if err := cmd.Run(); err != nil { outFile.Close() os.Remove(dumpFile) - return 0, fmt.Errorf("mysqldump执行失败: %w", err) + return 0, fmt.Errorf("数据库备份命令执行失败: %w", err) } outFile.Close() - // 获取文件大小 info, _ := os.Stat(dumpFile) dbSize := int64(0) if info != nil { dbSize = info.Size() } - // 写入tar file, err := os.Open(dumpFile) if err != nil { os.Remove(dumpFile) @@ -264,6 +264,51 @@ func (s *BackupService) backupDatabase(tarWriter *tar.Writer, timestamp string) return dbSize, nil } +// buildMySQLDump 构建 mysqldump 命令 +func (s *BackupService) buildMySQLDump(dbCfg config.DatabaseConfig, timestamp string) (string, *exec.Cmd, error) { + dumpFile := filepath.Join(os.TempDir(), fmt.Sprintf("db_dump_%s.sql", timestamp)) + mysqldumpPath := s.findTool("mysqldump", config.AppConfig.Backup.MysqlDumpPath) + cmd := exec.Command(mysqldumpPath, + "-h", dbCfg.Host, + "-P", fmt.Sprintf("%d", dbCfg.Port), + "-u", dbCfg.Username, + "-p"+dbCfg.Password, + "--single-transaction", "--routines", "--triggers", + dbCfg.DBName, + ) + return dumpFile, cmd, nil +} + +// buildPostgresDump 构建 pg_dump 命令 +func (s *BackupService) buildPostgresDump(dbCfg config.DatabaseConfig, timestamp string) (string, *exec.Cmd, error) { + dumpFile := filepath.Join(os.TempDir(), fmt.Sprintf("db_dump_%s.sql", timestamp)) + pgDumpPath := s.findTool("pg_dump", config.AppConfig.Backup.PgDumpPath) + cmd := exec.Command(pgDumpPath, + "-h", dbCfg.Host, + "-p", fmt.Sprintf("%d", dbCfg.Port), + "-U", dbCfg.Username, + "-F", "p", // plain text SQL + "--no-owner", + dbCfg.DBName, + ) + cmd.Env = append(os.Environ(), "PGPASSWORD="+dbCfg.Password) + return dumpFile, cmd, nil +} + +// buildSQLServerDump 构建 sqlcmd 导出命令 +func (s *BackupService) buildSQLServerDump(dbCfg config.DatabaseConfig, timestamp string) (string, *exec.Cmd, error) { + dumpFile := filepath.Join(os.TempDir(), fmt.Sprintf("db_dump_%s.sql", timestamp)) + sqlCmdPath := s.findTool("sqlcmd", config.AppConfig.Backup.SqlCmdPath) + cmd := exec.Command(sqlCmdPath, + "-S", fmt.Sprintf("%s,%d", dbCfg.Host, dbCfg.Port), + "-U", dbCfg.Username, + "-P", dbCfg.Password, + "-d", dbCfg.DBName, + "-Q", fmt.Sprintf("BACKUP DATABASE [%s] TO DISK='%s' WITH FORMAT", dbCfg.DBName, dumpFile), + ) + return dumpFile, cmd, nil +} + // backupFiles 备份文件 func (s *BackupService) backupFiles(tarWriter *tar.Writer) (int64, int, error) { basePath := config.AppConfig.Storage.LocalBasePath @@ -525,12 +570,23 @@ func (s *BackupService) extractTarGz(tarGzPath, destDir string) error { return nil } -// restoreDatabase 从SQL文件恢复数据库 +// restoreDatabase 从SQL文件恢复数据库(根据数据库类型选择工具) func (s *BackupService) restoreDatabase(sqlFilePath string) error { dbCfg := config.AppConfig.Database - mysqlPath := s.findTool("mysql", config.AppConfig.Backup.MysqlPath) + switch dbCfg.Type { + case "postgres": + return s.restorePostgres(dbCfg, sqlFilePath) + case "sqlserver": + return s.restoreSQLServer(dbCfg, sqlFilePath) + default: + return s.restoreMySQL(dbCfg, sqlFilePath) + } +} +// restoreMySQL mysql恢复 +func (s *BackupService) restoreMySQL(dbCfg config.DatabaseConfig, sqlFilePath string) error { + mysqlPath := s.findTool("mysql", config.AppConfig.Backup.MysqlPath) cmd := exec.Command(mysqlPath, "-h", dbCfg.Host, "-P", fmt.Sprintf("%d", dbCfg.Port), @@ -538,16 +594,42 @@ func (s *BackupService) restoreDatabase(sqlFilePath string) error { "-p"+dbCfg.Password, dbCfg.DBName, ) - sqlFile, err := os.Open(sqlFilePath) if err != nil { return err } defer sqlFile.Close() - cmd.Stdin = sqlFile cmd.Stderr = os.Stderr + return cmd.Run() +} +// restorePostgres psql恢复 +func (s *BackupService) restorePostgres(dbCfg config.DatabaseConfig, sqlFilePath string) error { + psqlPath := s.findTool("psql", config.AppConfig.Backup.PsqlPath) + cmd := exec.Command(psqlPath, + "-h", dbCfg.Host, + "-p", fmt.Sprintf("%d", dbCfg.Port), + "-U", dbCfg.Username, + "-d", dbCfg.DBName, + "-f", sqlFilePath, + ) + cmd.Env = append(os.Environ(), "PGPASSWORD="+dbCfg.Password) + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// restoreSQLServer sqlcmd恢复 +func (s *BackupService) restoreSQLServer(dbCfg config.DatabaseConfig, sqlFilePath string) error { + sqlCmdPath := s.findTool("sqlcmd", config.AppConfig.Backup.SqlCmdPath) + cmd := exec.Command(sqlCmdPath, + "-S", fmt.Sprintf("%s,%d", dbCfg.Host, dbCfg.Port), + "-U", dbCfg.Username, + "-P", dbCfg.Password, + "-d", dbCfg.DBName, + "-i", sqlFilePath, + ) + cmd.Stderr = os.Stderr return cmd.Run() } diff --git a/server/service/chunk_upload_service.go b/server/service/chunk_upload_service.go index 1906853..ae4e056 100644 --- a/server/service/chunk_upload_service.go +++ b/server/service/chunk_upload_service.go @@ -11,6 +11,7 @@ import ( "sort" "strconv" "strings" + "time" "seeyon-filesystem/config" "seeyon-filesystem/dto" @@ -34,6 +35,7 @@ func NewChunkUploadService() *ChunkUploadService { } // InitUpload 初始化分片上传 +// 根据存储策略自动选择: 本地模式(服务端中转) 或 MinIO模式(客户端直传) func (s *ChunkUploadService) InitUpload(fileName string, fileSize int64, md5 string, folderID, ownerID, policyID uint64) (*dto.ChunkUploadInitVO, error) { // 秒传检查 if md5 != "" { @@ -49,19 +51,28 @@ func (s *ChunkUploadService) InitUpload(fileName string, fileSize int64, md5 str } } - // 计算分片参数(5MB每片) + // 计算分片参数 chunkSize := int64(5 * 1024 * 1024) if fileSize < chunkSize { chunkSize = fileSize } totalChunks := int((fileSize + chunkSize - 1) / chunkSize) - // 创建临时目录 uploadID := uuid.New().String() + + // 生成存储路径 + ext := utils.GetExtension(fileName) + var storageKey string + if md5 != "" { + storageKey = storage.GenerateStorageKey(md5, ext) + } else { + storageKey = storage.GenerateStorageKey(fmt.Sprintf("%d-%d", ownerID, time.Now().UnixNano()), ext) + } + + // 创建上传会话 tempDir := filepath.Join(config.AppConfig.Storage.LocalBasePath, ".tmp", uploadID) os.MkdirAll(tempDir, 0755) - // 创建上传会话 session := &model.UploadSession{ ID: uploadID, FileName: fileName, @@ -73,22 +84,51 @@ func (s *ChunkUploadService) InitUpload(fileName string, fileSize int64, md5 str OwnerID: ownerID, PolicyID: policyID, Status: 1, - TempDir: tempDir, - } - - if err := config.DB.Create(session).Error; err != nil { - return nil, fmt.Errorf("创建上传会话失败: %w", err) + TempDir: storageKey, // 复用字段存储目标storageKey + } + config.DB.Create(session) + + // 检查是否为MinIO存储 + engine, err := storage.GetEngineByPolicyID(policyID) + if err == nil { + if minioEngine, ok := engine.(*storage.MinIOEngine); ok { + return s.initMinIOUpload(minioEngine, session, storageKey) + } } + // 本地存储模式 return &dto.ChunkUploadInitVO{ UploadID: uploadID, Instant: false, ChunkSize: chunkSize, Total: totalChunks, + Mode: "local", }, nil } -// UploadChunk 上传单个分片 +// initMinIOUpload 初始化MinIO直传模式 +func (s *ChunkUploadService) initMinIOUpload(minioEngine *storage.MinIOEngine, session *model.UploadSession, storageKey string) (*dto.ChunkUploadInitVO, error) { + // 为每个分片生成预签名上传URL + parts := make([]dto.ChunkPresignURL, session.TotalChunks) + for i := 0; i < session.TotalChunks; i++ { + chunkKey := fmt.Sprintf("%s.chunk_%06d", storageKey, i) + presignedURL, err := minioEngine.GetPresignedChunkURL(chunkKey, 24*time.Hour) + if err != nil { + presignedURL = "" + } + parts[i] = dto.ChunkPresignURL{Index: i, URL: presignedURL} + } + + return &dto.ChunkUploadInitVO{ + UploadID: session.ID, + ChunkSize: session.ChunkSize, + Total: session.TotalChunks, + Mode: "minio", + Parts: parts, + }, nil +} + +// UploadChunk 上传单个分片(本地模式) func (s *ChunkUploadService) UploadChunk(uploadID string, index int, reader io.Reader, checksum string) (*dto.ChunkUploadChunkVO, error) { var session model.UploadSession if err := config.DB.First(&session, uploadID).Error; err != nil { @@ -101,14 +141,12 @@ func (s *ChunkUploadService) UploadChunk(uploadID string, index int, reader io.R return nil, errors.New("分片索引无效") } - // 检查是否已上传 uploaded := parseChunkIndices(session.UploadedChunks) if uploaded[index] { return &dto.ChunkUploadChunkVO{Index: index, Status: "exists"}, nil } - // 写入临时文件 - chunkPath := filepath.Join(session.TempDir, fmt.Sprintf("chunk_%06d", index)) + chunkPath := filepath.Join(config.AppConfig.Storage.LocalBasePath, ".tmp", uploadID, fmt.Sprintf("chunk_%06d", index)) dst, err := os.Create(chunkPath) if err != nil { return nil, fmt.Errorf("创建分片文件失败: %w", err) @@ -123,14 +161,12 @@ func (s *ChunkUploadService) UploadChunk(uploadID string, index int, reader io.R return nil, fmt.Errorf("写入分片失败: %w", err) } - // checksum校验 actualChecksum := hex.EncodeToString(hash.Sum(nil)) if checksum != "" && checksum != actualChecksum { os.Remove(chunkPath) return nil, fmt.Errorf("分片校验失败: 期望 %s, 实际 %s", checksum, actualChecksum) } - // 更新进度 uploaded[index] = true chunkStr := buildChunkIndices(uploaded) config.DB.Model(&session).Update("uploaded_chunks", chunkStr) @@ -138,6 +174,24 @@ func (s *ChunkUploadService) UploadChunk(uploadID string, index int, reader io.R return &dto.ChunkUploadChunkVO{Index: index, Size: size, Status: "ok"}, nil } +// UploadChunkMinIO 确认MinIO分片上传完成(客户端直传后调用) +func (s *ChunkUploadService) UploadChunkMinIO(uploadID string, index int, etag string) (*dto.ChunkUploadChunkVO, error) { + var session model.UploadSession + if err := config.DB.First(&session, uploadID).Error; err != nil { + return nil, errors.New("上传会话不存在") + } + if session.Status != 1 { + return nil, errors.New("上传会话已结束") + } + + uploaded := parseChunkIndices(session.UploadedChunks) + uploaded[index] = true + chunkStr := buildChunkIndices(uploaded) + config.DB.Model(&session).Update("uploaded_chunks", chunkStr) + + return &dto.ChunkUploadChunkVO{Index: index, Status: "ok", ETag: etag}, nil +} + // MergeChunks 合并分片完成上传 func (s *ChunkUploadService) MergeChunks(uploadID string) (*dto.FileVO, error) { var session model.UploadSession @@ -148,7 +202,6 @@ func (s *ChunkUploadService) MergeChunks(uploadID string) (*dto.FileVO, error) { return nil, errors.New("上传会话已结束") } - // 检查完整性 uploaded := parseChunkIndices(session.UploadedChunks) for i := 0; i < session.TotalChunks; i++ { if !uploaded[i] { @@ -156,14 +209,30 @@ func (s *ChunkUploadService) MergeChunks(uploadID string) (*dto.FileVO, error) { } } - // 合并分片 - mergedPath := filepath.Join(session.TempDir, "merged") + storageKey := session.TempDir // 目标storageKey + + // 检测MinIO模式 + engine, _ := storage.GetEngineByPolicyID(session.PolicyID) + if minioEngine, ok := engine.(*storage.MinIOEngine); ok { + return s.mergeMinIO(minioEngine, &session, storageKey) + } + + // 本地模式: 合并分片文件 + return s.mergeLocal(&session, storageKey) +} + +// mergeLocal 本地模式合并 +func (s *ChunkUploadService) mergeLocal(session *model.UploadSession, storageKey string) (*dto.FileVO, error) { + tmpDir := filepath.Join(config.AppConfig.Storage.LocalBasePath, ".tmp", session.ID) + mergedPath := filepath.Join(tmpDir, "merged") + merged, err := os.Create(mergedPath) if err != nil { return nil, fmt.Errorf("创建合并文件失败: %w", err) } + for i := 0; i < session.TotalChunks; i++ { - chunkPath := filepath.Join(session.TempDir, fmt.Sprintf("chunk_%06d", i)) + chunkPath := filepath.Join(tmpDir, fmt.Sprintf("chunk_%06d", i)) chunk, err := os.Open(chunkPath) if err != nil { merged.Close() @@ -174,22 +243,20 @@ func (s *ChunkUploadService) MergeChunks(uploadID string) (*dto.FileVO, error) { } merged.Close() - // 计算MD5 fileMD5, _ := utils.FileMD5(mergedPath) fileSHA256, _ := utils.FileSHA256(mergedPath) - // 验证MD5 if session.MD5 != "" && session.MD5 != fileMD5 { - os.RemoveAll(session.TempDir) - return nil, fmt.Errorf("文件MD5校验失败: 期望 %s, 实际 %s", session.MD5, fileMD5) + os.RemoveAll(tmpDir) + return nil, fmt.Errorf("MD5校验失败: 期望 %s, 实际 %s", session.MD5, fileMD5) } // 秒传检查 existing, _ := s.fileRepo.FindByMD5(fileMD5) if existing != nil { - os.RemoveAll(session.TempDir) + os.RemoveAll(tmpDir) newFile := &model.File{ - Name: session.FileName, Extension: utils.GetExtension(session.FileName), + ID: utils.GenID(), Name: session.FileName, Extension: utils.GetExtension(session.FileName), FolderID: session.FolderID, OwnerID: session.OwnerID, Size: session.FileSize, MD5: fileMD5, StorageKey: existing.StorageKey, StoragePolicyID: existing.StoragePolicyID, MimeType: utils.GetMimeType(utils.GetExtension(session.FileName)), Status: 1, @@ -200,36 +267,60 @@ func (s *ChunkUploadService) MergeChunks(uploadID string) (*dto.FileVO, error) { // 上传到存储引擎 ext := utils.GetExtension(session.FileName) - storageKey := storage.GenerateStorageKey(fileMD5, ext) - engine, err := storage.GetEngineByPolicyID(session.PolicyID) - if err != nil { - engine = storage.GetDefaultEngine() - } + engine, _ := storage.GetEngineByPolicyID(session.PolicyID) reader, _ := os.Open(mergedPath) defer reader.Close() if err := engine.Upload(storageKey, reader, session.FileSize); err != nil { - os.RemoveAll(session.TempDir) + os.RemoveAll(tmpDir) return nil, fmt.Errorf("存储文件失败: %w", err) } - // 保存记录 newFile := &model.File{ - Name: session.FileName, Extension: ext, FolderID: session.FolderID, OwnerID: session.OwnerID, + ID: utils.GenID(), Name: session.FileName, Extension: ext, FolderID: session.FolderID, OwnerID: session.OwnerID, Size: session.FileSize, MD5: fileMD5, SHA256: fileSHA256, StorageKey: storageKey, StoragePolicyID: session.PolicyID, MimeType: utils.GetMimeType(ext), Status: 1, } if err := s.fileRepo.Create(newFile); err != nil { engine.Delete(storageKey) - os.RemoveAll(session.TempDir) + os.RemoveAll(tmpDir) return nil, fmt.Errorf("保存文件记录失败: %w", err) } - config.DB.Model(&session).Updates(map[string]interface{}{"status": 2, "file_id": newFile.ID, "md5": fileMD5, "sha256": fileSHA256}) - os.RemoveAll(session.TempDir) + config.DB.Model(session).Updates(map[string]interface{}{"status": 2, "file_id": newFile.ID, "md5": fileMD5, "sha256": fileSHA256}) + os.RemoveAll(tmpDir) return fileToVO(newFile), nil } -// GetUploadProgress 查询上传进度(断点续传) +// mergeMinIO MinIO模式合并 +func (s *ChunkUploadService) mergeMinIO(minioEngine *storage.MinIOEngine, session *model.UploadSession, storageKey string) (*dto.FileVO, error) { + // 构建分片key列表 + chunkKeys := make([]string, session.TotalChunks) + for i := 0; i < session.TotalChunks; i++ { + chunkKeys[i] = fmt.Sprintf("%s.chunk_%06d", storageKey, i) + } + + // 合并分片 + if err := minioEngine.MergeChunks(chunkKeys, storageKey); err != nil { + return nil, fmt.Errorf("MinIO合并失败: %w", err) + } + + ext := utils.GetExtension(session.FileName) + fileMD5 := session.MD5 // 使用客户端提供的MD5 + + newFile := &model.File{ + ID: utils.GenID(), Name: session.FileName, Extension: ext, FolderID: session.FolderID, OwnerID: session.OwnerID, + Size: session.FileSize, MD5: fileMD5, StorageKey: storageKey, + StoragePolicyID: session.PolicyID, MimeType: utils.GetMimeType(ext), Status: 1, + } + if err := s.fileRepo.Create(newFile); err != nil { + return nil, fmt.Errorf("保存文件记录失败: %w", err) + } + + config.DB.Model(session).Updates(map[string]interface{}{"status": 2, "file_id": newFile.ID, "md5": fileMD5}) + return fileToVO(newFile), nil +} + +// GetUploadProgress 查询上传进度 func (s *ChunkUploadService) GetUploadProgress(uploadID string) (*dto.ChunkUploadProgressVO, error) { var session model.UploadSession if err := config.DB.First(&session, uploadID).Error; err != nil { @@ -255,7 +346,7 @@ func (s *ChunkUploadService) CancelUpload(uploadID string) error { if err := config.DB.First(&session, uploadID).Error; err != nil { return errors.New("上传会话不存在") } - os.RemoveAll(session.TempDir) + os.RemoveAll(filepath.Join(config.AppConfig.Storage.LocalBasePath, ".tmp", uploadID)) config.DB.Model(&session).Update("status", 3) return nil } @@ -288,7 +379,8 @@ func buildChunkIndices(m map[int]bool) string { func fileToVO(file *model.File) *dto.FileVO { vo := &dto.FileVO{ - ID: file.ID, Name: file.Name, Extension: file.Extension, FolderID: file.FolderID, + ID: dto.StringID(file.ID), Name: file.Name, Extension: file.Extension, FolderID: dto.StringID(file.FolderID), + OwnerID: dto.StringID(file.OwnerID), Size: file.Size, StorageKey: file.StorageKey, StoragePolicyID: file.StoragePolicyID, MimeType: file.MimeType, IsFavorite: file.IsFavorite, DownloadCount: file.DownloadCount, Version: file.Version, CreatedAt: file.CreatedAt, UpdatedAt: file.UpdatedAt, @@ -299,6 +391,8 @@ func fileToVO(file *model.File) *dto.FileVO { if localEngine, ok := engine.(*storage.LocalEngine); ok { vo.DownloadURL = localEngine.GetURL(file.StorageKey) vo.PreviewURL = localEngine.GetURL(file.StorageKey) + } else if minioEngine, ok := engine.(*storage.MinIOEngine); ok { + vo.PreviewURL = minioEngine.GetPreviewURL(file.StorageKey) } } } diff --git a/server/service/file_service.go b/server/service/file_service.go index e337bae..d22f880 100644 --- a/server/service/file_service.go +++ b/server/service/file_service.go @@ -7,6 +7,7 @@ import ( "mime/multipart" "os" "path/filepath" + "strings" "time" "seeyon-filesystem/config" @@ -36,9 +37,14 @@ func NewFileService() *FileService { } // Upload 上传文件 -// 流程: 计算MD5 -> 检查秒传 -> 存储文件 -> 保存元数据 -// storagePolicyID: 存储策略ID, 0=使用默认策略 +// 流程: 校验大小 -> 计算MD5 -> 检查秒传 -> 存储文件 -> 保存元数据 func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64, ownerID uint64, storagePolicyID uint64) (*dto.FileVO, error) { + // 校验文件大小 + maxSize := s.getMaxFileSize(ownerID) + if maxSize > 0 && fileHeader.Size > maxSize { + return nil, fmt.Errorf("文件大小 %s 超过限制 %s", formatBytes(fileHeader.Size), formatBytes(maxSize)) + } + // 验证文件夹 if folderID > 0 { folder, err := s.folderRepo.FindByID(folderID) @@ -99,6 +105,7 @@ func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64, existing, _ := s.fileRepo.FindByMD5(md5) if existing != nil { newFile := &model.File{ + ID: utils.GenID(), Name: fileHeader.Filename, Extension: utils.GetExtension(fileHeader.Filename), FolderID: folderID, @@ -132,6 +139,7 @@ func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64, } newFile := &model.File{ + ID: utils.GenID(), Name: fileHeader.Filename, Extension: ext, FolderID: folderID, @@ -159,7 +167,7 @@ func (s *FileService) GetByID(id uint64, ownerID uint64) (*dto.FileVO, error) { if err != nil { return nil, errors.New("文件不存在") } - if file.OwnerID != ownerID { + if !s.canAccessFile(file.ID, file.OwnerID, ownerID) { return nil, errors.New("无权访问此文件") } return s.toFileVO(file), nil @@ -171,7 +179,7 @@ func (s *FileService) Download(id uint64, ownerID uint64) (io.ReadCloser, *model if err != nil { return nil, nil, errors.New("文件不存在") } - if file.OwnerID != ownerID { + if !s.canAccessFile(file.ID, file.OwnerID, ownerID) { return nil, nil, errors.New("无权下载此文件") } return s.downloadFile(file, id) @@ -210,7 +218,7 @@ func (s *FileService) Rename(id uint64, newName string, ownerID uint64, version if err != nil { return errors.New("文件不存在") } - if file.OwnerID != ownerID { + if !s.canAccessFile(file.ID, file.OwnerID, ownerID) { return errors.New("无权操作此文件") } @@ -243,7 +251,7 @@ func (s *FileService) Move(id uint64, targetFolderID uint64, ownerID uint64) err if err != nil { return errors.New("文件不存在") } - if file.OwnerID != ownerID { + if !s.canAccessFile(file.ID, file.OwnerID, ownerID) { return errors.New("无权操作此文件") } @@ -267,7 +275,7 @@ func (s *FileService) SoftDelete(id uint64, ownerID uint64) error { if err != nil { return errors.New("文件不存在") } - if file.OwnerID != ownerID { + if !s.canAccessFile(file.ID, file.OwnerID, ownerID) { return errors.New("无权操作此文件") } @@ -280,7 +288,7 @@ func (s *FileService) Restore(id uint64, ownerID uint64) error { if err != nil { return errors.New("文件不存在") } - if file.OwnerID != ownerID { + if !s.canAccessFile(file.ID, file.OwnerID, ownerID) { return errors.New("无权操作此文件") } @@ -293,7 +301,7 @@ func (s *FileService) HardDelete(id uint64, ownerID uint64) error { if err != nil { return errors.New("文件不存在") } - if file.OwnerID != ownerID { + if !s.canAccessFile(file.ID, file.OwnerID, ownerID) { return errors.New("无权操作此文件") } @@ -308,53 +316,102 @@ func (s *FileService) HardDelete(id uint64, ownerID uint64) error { } // GetDownloadURL 生成文件下载链接 -func (s *FileService) GetDownloadURL(id uint64) (string, error) { +func (s *FileService) GetDownloadURL(id uint64, token ...string) (string, error) { file, err := s.fileRepo.FindByID(id) if err != nil { return "", errors.New("文件不存在") } - return s.generateFileURL(file, "download") + return GenerateFileURL(file, "download", token...) } // GetPreviewURL 生成文件预览链接 -func (s *FileService) GetPreviewURL(id uint64) (string, error) { +func (s *FileService) GetPreviewURL(id uint64, token ...string) (string, error) { file, err := s.fileRepo.FindByID(id) if err != nil { return "", errors.New("文件不存在") } - return s.generateFileURL(file, "preview") + return GenerateFileURL(file, "preview", token...) } // generateFileURL 统一生成文件URL func (s *FileService) generateFileURL(file *model.File, urlType string) (string, error) { - engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID) - if err != nil { - return fmt.Sprintf("/api/file/%d/%s", file.ID, urlType), nil + return GenerateFileURL(file, urlType) +} + +// GenerateFileURL 统一生成文件URL(包级别, 供其他service调用) +// download: 返回直接下载链接 +// preview: 返回在线预览链接(Content-Disposition: inline) +// token: 可选, 用于本地存储时拼接到URL中供浏览器直接访问 +func GenerateFileURL(file *model.File, urlType string, token ...string) (string, error) { + var tk string + if len(token) > 0 { + tk = token[0] } - // 本地存储: 使用CDN地址 - if localEngine, ok := engine.(*storage.LocalEngine); ok { - if localEngine.CDNHost != "" { - return localEngine.GetURL(file.StorageKey), nil - } - return fmt.Sprintf("/api/file/%d/%s", file.ID, urlType), nil - } + // 根据存储策略的实际类型决定URL生成方式 + policyType := getStoragePolicyType(file.StoragePolicyID) - // MinIO存储: 生成预签名URL并替换为Nginx代理地址 - if minioEngine, ok := engine.(*storage.MinIOEngine); ok { - presignedURL, err := minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour) + switch policyType { + case "minio": + engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID) if err != nil { - return fmt.Sprintf("/api/file/%d/%s", file.ID, urlType), nil + // MinIO不可用, 回退到API代理 + return buildAPIURL(file.ID, urlType, tk), nil + } + minioEngine := engine.(*storage.MinIOEngine) + var presignedURL string + var genErr error + if urlType == "preview" { + presignedURL, genErr = minioEngine.GetPresignedPreviewURL(file.StorageKey, file.MimeType, 24*time.Hour) + } else { + presignedURL, genErr = minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour) + } + if genErr != nil { + return buildAPIURL(file.ID, urlType, tk), nil } - // 替换为Nginx代理地址 policy := getStoragePolicyConfig(file.StoragePolicyID) if policy != nil && policy.NginxEndpoint != "" { presignedURL = replaceMinIOEndpoint(presignedURL, minioEngine.Endpoint, policy.NginxEndpoint) } return presignedURL, nil - } - return fmt.Sprintf("/api/file/%d/%s", file.ID, urlType), nil + case "local": + // 本地存储: 有CDN就用CDN, 否则用API路径 + cfg := config.AppConfig.Storage + if cfg.CDNHost != "" { + prefix := strings.TrimRight(cfg.CDNPathPrefix, "/") + key := strings.TrimLeft(file.StorageKey, "/") + return fmt.Sprintf("%s%s/%s", cfg.CDNHost, prefix, key), nil + } + return buildAPIURL(file.ID, urlType, tk), nil + + default: + // 未知策略或策略不存在, 回退到API代理 + return buildAPIURL(file.ID, urlType, tk), nil + } +} + +// getStoragePolicyType 获取存储策略类型 +func getStoragePolicyType(policyID uint64) string { + var policy model.StoragePolicy + if config.DB.Select("type").First(&policy, policyID).Error != nil { + return "" + } + return policy.Type +} + +// buildAPIURL 构建完整的API文件访问地址 +func buildAPIURL(fileID uint64, urlType string, token ...string) string { + publicURL := config.AppConfig.Server.PublicURL + if publicURL == "" { + publicURL = fmt.Sprintf("http://localhost:%d", config.AppConfig.Server.Port) + } + publicURL = strings.TrimRight(publicURL, "/") + base := fmt.Sprintf("%s/api/file/%d/%s", publicURL, fileID, urlType) + if len(token) > 0 && token[0] != "" { + base += "?token=" + token[0] + } + return base } // getStoragePolicyConfig 获取存储策略配置 @@ -441,10 +498,12 @@ func (s *FileService) GetDownloadPath(storageKey string) string { // toFileVO 转换为视图对象 func (s *FileService) toFileVO(file *model.File) *dto.FileVO { vo := &dto.FileVO{ - ID: file.ID, + ID: dto.StringID(file.ID), Name: file.Name, Extension: file.Extension, - FolderID: file.FolderID, + FolderID: dto.StringID(file.FolderID), + OwnerID: dto.StringID(file.OwnerID), + OwnerName: getUserName(file.OwnerID), Size: file.Size, StorageKey: file.StorageKey, StoragePolicyID: file.StoragePolicyID, @@ -477,3 +536,227 @@ func (s *FileService) getUserRoleIDs(userID uint64) []uint64 { } return roleIDs } + +// canAccessFile 检查用户是否有权操作该文件 +// 规则: owner → 有权限 | admin → 有权限 | fs_file_permission中被授权 → 有权限 +func (s *FileService) canAccessFile(fileID, fileOwnerID, userID uint64) bool { + // 1. 文件所有者 + if fileOwnerID == userID { + return true + } + // 2. 管理员 + if s.isAdmin(userID) { + return true + } + // 3. 文件级授权 + var count int64 + config.DB.Model(&model.FilePermission{}). + Where("file_id = ? AND (user_id = ? OR role_id IN (?))", + fileID, userID, + config.DB.Model(&model.UserRole{}).Select("role_id").Where("user_id = ?", userID), + ).Count(&count) + return count > 0 +} + +// isAdmin 检查用户是否为管理员 +func (s *FileService) isAdmin(userID uint64) bool { + var userRoles []model.UserRole + config.DB.Where("user_id = ?", userID).Find(&userRoles) + for _, ur := range userRoles { + var role model.Role + if config.DB.First(&role, ur.RoleID).Error == nil && role.Name == "admin" { + return true + } + } + return false +} + +// getMaxFileSize 根据用户角色返回最大文件大小(字节), 0=不限制 +func (s *FileService) getMaxFileSize(userID uint64) int64 { + var userRoles []model.UserRole + config.DB.Where("user_id = ?", userID).Find(&userRoles) + + roleIDs := make([]uint64, 0, len(userRoles)) + for _, ur := range userRoles { + roleIDs = append(roleIDs, ur.RoleID) + } + + var roles []model.Role + if len(roleIDs) > 0 { + config.DB.Where("id IN ?", roleIDs).Find(&roles) + } + + for _, r := range roles { + switch r.Name { + case "admin": + return 0 // 不限制 + case "staff": + return 500 * 1024 * 1024 // 500MB + case "user": + return 100 * 1024 * 1024 // 100MB + case "guest": + return 0 // 不允许上传 + } + } + return 100 * 1024 * 1024 // 默认100MB +} + +// formatBytes 格式化字节数 +func formatBytes(bytes int64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + ) + switch { + case bytes >= GB: + return fmt.Sprintf("%.1fGB", float64(bytes)/float64(GB)) + case bytes >= MB: + return fmt.Sprintf("%.1fMB", float64(bytes)/float64(MB)) + case bytes >= KB: + return fmt.Sprintf("%.1fKB", float64(bytes)/float64(KB)) + default: + return fmt.Sprintf("%dB", bytes) + } +} + +// ========== 文件权限管理 ========== + +// GrantPermission 授权文件权限给用户或角色 +func (s *FileService) GrantPermission(fileID uint64, grantBy uint64, req *dto.GrantPermissionRequest) error { + // 验证文件存在且属于授权者 + file, err := s.fileRepo.FindByID(fileID) + if err != nil { + return errors.New("文件不存在") + } + if !s.canAccessFile(file.ID, file.OwnerID, grantBy) { + return errors.New("只有文件所有者或管理员才能授权") + } + + // user_id 和 role_id 二选一 + userID := uint64(req.UserID) + roleID := uint64(req.RoleID) + if userID == 0 && roleID == 0 { + return errors.New("请指定 user_id 或 role_id") + } + if userID > 0 && roleID > 0 { + return errors.New("user_id 和 role_id 只能选一个") + } + + // 验证权限级别 + switch req.PermLevel { + case "read", "write", "admin": + default: + return errors.New("perm_level 必须是 read / write / admin") + } + + // 去重: 同一文件+同一用户/角色只保留一条 + var existing model.FilePermission + query := config.DB.Where("file_id = ?", fileID) + if userID > 0 { + query = query.Where("user_id = ?", userID) + } else { + query = query.Where("role_id = ?", roleID) + } + if query.First(&existing).Error == nil { + // 已存在则更新 + existing.PermLevel = req.PermLevel + existing.GrantBy = grantBy + return config.DB.Save(&existing).Error + } + + return config.DB.Create(&model.FilePermission{ + FileID: fileID, + UserID: userID, + RoleID: roleID, + PermLevel: req.PermLevel, + GrantBy: grantBy, + }).Error +} + +// RevokePermission 撤销文件权限 +func (s *FileService) RevokePermission(fileID uint64, permID uint64, userID uint64) error { + // 验证文件归属 + file, err := s.fileRepo.FindByID(fileID) + if err != nil { + return errors.New("文件不存在") + } + if !s.canAccessFile(file.ID, file.OwnerID, userID) { + return errors.New("只有文件所有者或管理员才能撤销权限") + } + + result := config.DB.Where("id = ? AND file_id = ?", permID, fileID).Delete(&model.FilePermission{}) + if result.RowsAffected == 0 { + return errors.New("权限记录不存在") + } + return result.Error +} + +// ListPermissions 查看文件的授权列表 +func (s *FileService) ListPermissions(fileID uint64, userID uint64) ([]dto.FilePermissionVO, error) { + file, err := s.fileRepo.FindByID(fileID) + if err != nil { + return nil, errors.New("文件不存在") + } + if !s.canAccessFile(file.ID, file.OwnerID, userID) { + return nil, errors.New("只有文件所有者或管理员才能查看权限列表") + } + + var perms []model.FilePermission + config.DB.Where("file_id = ?", fileID).Find(&perms) + + vos := make([]dto.FilePermissionVO, 0, len(perms)) + for _, p := range perms { + vo := dto.FilePermissionVO{ + ID: p.ID, + FileID: p.FileID, + UserID: p.UserID, + RoleID: p.RoleID, + PermLevel: p.PermLevel, + GrantBy: p.GrantBy, + CreatedAt: p.CreatedAt.Format("2006-01-02 15:04:05"), + } + // 填充用户名 + if p.UserID > 0 { + var user model.User + if config.DB.Select("nickname", "username").First(&user, p.UserID).Error == nil { + vo.UserName = user.Nickname + if vo.UserName == "" { + vo.UserName = user.Username + } + } + } + // 填充角色名 + if p.RoleID > 0 { + var role model.Role + if config.DB.Select("display_name", "name").First(&role, p.RoleID).Error == nil { + vo.RoleName = role.DisplayName + if vo.RoleName == "" { + vo.RoleName = role.Name + } + } + } + // 填充授权人 + var granter model.User + if config.DB.Select("nickname", "username").First(&granter, p.GrantBy).Error == nil { + vo.GrantByName = granter.Nickname + if vo.GrantByName == "" { + vo.GrantByName = granter.Username + } + } + vos = append(vos, vo) + } + return vos, nil +} + +// getUserName 根据用户ID获取显示名称 +func getUserName(userID uint64) string { + var user model.User + if config.DB.Select("nickname", "username").First(&user, userID).Error != nil { + return "" + } + if user.Nickname != "" { + return user.Nickname + } + return user.Username +} diff --git a/server/service/folder_service.go b/server/service/folder_service.go index 2c3f6c1..ccd9824 100644 --- a/server/service/folder_service.go +++ b/server/service/folder_service.go @@ -8,6 +8,7 @@ import ( "seeyon-filesystem/dto" "seeyon-filesystem/model" "seeyon-filesystem/repository" + "seeyon-filesystem/utils" ) // FolderService 文件夹业务服务 @@ -26,30 +27,35 @@ func NewFolderService() *FolderService { // Create 创建文件夹 func (s *FolderService) Create(req *dto.CreateFolderRequest, ownerID uint64) (*dto.FolderVO, error) { + parentID := uint64(req.ParentID) + // 构建物化路径 parentPath := "/" - if req.ParentID > 0 { - parent, err := s.folderRepo.FindByID(req.ParentID) + if parentID > 0 { + parent, err := s.folderRepo.FindByID(parentID) if err != nil { return nil, errors.New("父文件夹不存在") } - if parent.OwnerID != ownerID { + if !s.canAccessFolder(parent.OwnerID, ownerID) { return nil, errors.New("无权访问父文件夹") } parentPath = parent.Path } // 检查同名文件夹 - existing, _ := s.folderRepo.FindByNameAndParent(req.Name, req.ParentID, ownerID) + existing, _ := s.folderRepo.FindByNameAndParent(req.Name, parentID, ownerID) if existing != nil { return nil, errors.New("同名文件夹已存在") } + folderID := utils.GenID() folder := &model.Folder{ + ID: folderID, + BizID: req.BizID, Name: req.Name, - ParentID: req.ParentID, + ParentID: parentID, OwnerID: ownerID, - Path: parentPath, // 先创建, 获取ID后再更新路径 + Path: fmt.Sprintf("%s%d/", parentPath, folderID), Status: 1, } @@ -57,12 +63,6 @@ func (s *FolderService) Create(req *dto.CreateFolderRequest, ownerID uint64) (*d return nil, fmt.Errorf("创建文件夹失败: %w", err) } - // 更新物化路径 - folder.Path = fmt.Sprintf("%s%d/", parentPath, folder.ID) - if err := s.folderRepo.Update(folder); err != nil { - return nil, fmt.Errorf("更新文件夹路径失败: %w", err) - } - return s.toFolderVO(folder), nil } @@ -72,16 +72,26 @@ func (s *FolderService) GetByID(id uint64, ownerID uint64) (*dto.FolderVO, error if err != nil { return nil, errors.New("文件夹不存在") } - if folder.OwnerID != ownerID { + if !s.canAccessFolder(folder.OwnerID, ownerID) { return nil, errors.New("无权访问此文件夹") } return s.toFolderVO(folder), nil } -// ListChildren 获取文件夹的子项(文件+文件夹, 基于权限) -func (s *FolderService) ListChildren(folderID uint64, userID uint64) (*dto.FileListVO, error) { - // 获取用户角色IDs +// GetByBizID 根据业务ID获取文件夹 +func (s *FolderService) GetByBizID(bizID string, ownerID uint64) (*dto.FolderVO, error) { + folder, err := s.folderRepo.FindByBizID(bizID, ownerID) + if err != nil { + return nil, errors.New("文件夹不存在") + } + return s.toFolderVO(folder), nil +} + +// ListChildren 获取文件夹的子项(文件+文件夹, 基于权限, 支持分页) +// 管理员看全部, 非管理员只看自己创建的 + 被授权的 +func (s *FolderService) ListChildren(folderID uint64, userID uint64, page, pageSize int) (*dto.FileListVO, error) { roleIDs := s.getUserRoleIDs(userID) + isAdmin := s.isAdmin(roleIDs) // 如果指定了文件夹ID, 验证访问权限 if folderID > 0 { @@ -89,19 +99,32 @@ func (s *FolderService) ListChildren(folderID uint64, userID uint64) (*dto.FileL if err != nil { return nil, errors.New("文件夹不存在") } - if folder.OwnerID != userID { + if !isAdmin && folder.OwnerID != userID { return nil, errors.New("无权访问此文件夹") } } - // 查询子文件夹(暂时只查自己创建的) - folders, err := s.folderRepo.ListByParent(folderID, userID) - if err != nil { - return nil, err + // 查询子文件夹 + var folders []model.Folder + var totalFolders int64 + if isAdmin { + folders, _ = s.folderRepo.ListByParentAll(folderID) + totalFolders, _ = s.folderRepo.CountByParentAll(folderID) + } else { + folders, _ = s.folderRepo.ListByParent(folderID, userID) + totalFolders, _ = s.folderRepo.CountByParent(folderID, userID) } - // 查询子文件(基于权限: 自己的 + 被授权的) - files, err := s.fileRepo.ListByFolder(folderID, userID, roleIDs) + // 分页查询子文件 + var files []model.File + var totalFiles int64 + var err error + if pageSize > 0 { + files, totalFiles, err = s.fileRepo.ListByFolderPaginated(folderID, userID, roleIDs, page, pageSize) + } else { + files, err = s.fileRepo.ListByFolder(folderID, userID, roleIDs) + totalFiles = int64(len(files)) + } if err != nil { return nil, err } @@ -118,18 +141,40 @@ func (s *FolderService) ListChildren(folderID uint64, userID uint64) (*dto.FileL } return &dto.FileListVO{ - Folders: folderVOs, - Files: fileVOs, + Folders: folderVOs, + Files: fileVOs, + TotalFiles: totalFiles, + TotalFolders: totalFolders, + Page: page, + PageSize: pageSize, }, nil } +// canAccessFolder 检查用户是否有权操作该文件夹(owner / admin) +func (s *FolderService) canAccessFolder(folderOwnerID, userID uint64) bool { + if folderOwnerID == userID { + return true + } + return s.isAdmin(s.getUserRoleIDs(userID)) +} + +// isAdmin 检查用户是否为管理员 +func (s *FolderService) isAdmin(roleIDs []uint64) bool { + if len(roleIDs) == 0 { + return false + } + var count int64 + config.DB.Model(&model.Role{}).Where("id IN ? AND name = ?", roleIDs, "admin").Count(&count) + return count > 0 +} + // Rename 重命名文件夹(乐观锁) func (s *FolderService) Rename(id uint64, newName string, ownerID uint64, version int) error { folder, err := s.folderRepo.FindByID(id) if err != nil { return errors.New("文件夹不存在") } - if folder.OwnerID != ownerID { + if !s.canAccessFolder(folder.OwnerID, ownerID) { return errors.New("无权操作此文件夹") } @@ -158,7 +203,7 @@ func (s *FolderService) Delete(id uint64, ownerID uint64) error { if err != nil { return errors.New("文件夹不存在") } - if folder.OwnerID != ownerID { + if !s.canAccessFolder(folder.OwnerID, ownerID) { return errors.New("无权操作此文件夹") } @@ -168,9 +213,12 @@ func (s *FolderService) Delete(id uint64, ownerID uint64) error { // toFolderVO 转换为视图对象 func (s *FolderService) toFolderVO(folder *model.Folder) *dto.FolderVO { return &dto.FolderVO{ - ID: folder.ID, + ID: dto.StringID(folder.ID), + BizID: folder.BizID, Name: folder.Name, - ParentID: folder.ParentID, + ParentID: dto.StringID(folder.ParentID), + OwnerID: dto.StringID(folder.OwnerID), + OwnerName: s.getUserName(folder.OwnerID), Path: folder.Path, Version: folder.Version, CreatedAt: folder.CreatedAt, @@ -178,6 +226,18 @@ func (s *FolderService) toFolderVO(folder *model.Folder) *dto.FolderVO { } } +// getUserName 根据用户ID获取显示名称 +func (s *FolderService) getUserName(userID uint64) string { + var user model.User + if config.DB.Select("nickname", "username").First(&user, userID).Error != nil { + return "" + } + if user.Nickname != "" { + return user.Nickname + } + return user.Username +} + // getUserRoleIDs 获取用户的角色ID列表 func (s *FolderService) getUserRoleIDs(userID uint64) []uint64 { var userRoles []model.UserRole @@ -191,16 +251,31 @@ func (s *FolderService) getUserRoleIDs(userID uint64) []uint64 { // toFileVO 转换为视图对象 func (s *FolderService) toFileVO(file *model.File) *dto.FileVO { - return &dto.FileVO{ - ID: file.ID, - Name: file.Name, - Extension: file.Extension, - FolderID: file.FolderID, - Size: file.Size, - MimeType: file.MimeType, - IsFavorite: file.IsFavorite, - DownloadCount: file.DownloadCount, - CreatedAt: file.CreatedAt, - UpdatedAt: file.UpdatedAt, + vo := &dto.FileVO{ + ID: dto.StringID(file.ID), + Name: file.Name, + Extension: file.Extension, + FolderID: dto.StringID(file.FolderID), + OwnerID: dto.StringID(file.OwnerID), + OwnerName: s.getUserName(file.OwnerID), + Size: file.Size, + StorageKey: file.StorageKey, + StoragePolicyID: file.StoragePolicyID, + MimeType: file.MimeType, + IsFavorite: file.IsFavorite, + DownloadCount: file.DownloadCount, + Version: file.Version, + CreatedAt: file.CreatedAt, + UpdatedAt: file.UpdatedAt, } + + // 生成下载/预览链接 + if file.StorageKey != "" { + downloadURL, _ := GenerateFileURL(file, "download") + previewURL, _ := GenerateFileURL(file, "preview") + vo.DownloadURL = downloadURL + vo.PreviewURL = previewURL + } + + return vo } diff --git a/server/service/share_service.go b/server/service/share_service.go index 3e59d66..0c83289 100644 --- a/server/service/share_service.go +++ b/server/service/share_service.go @@ -32,7 +32,8 @@ func NewShareService() *ShareService { // Create 创建分享链接 func (s *ShareService) Create(req *dto.ShareCreateRequest, ownerID uint64) (*dto.ShareVO, error) { // 验证文件存在 - file, err := s.fileRepo.FindByID(req.FileID) + fileID := uint64(req.FileID) + file, err := s.fileRepo.FindByID(fileID) if err != nil { return nil, errors.New("文件不存在") } @@ -47,7 +48,7 @@ func (s *ShareService) Create(req *dto.ShareCreateRequest, ownerID uint64) (*dto } share := &model.Share{ - FileID: req.FileID, + FileID: fileID, OwnerID: ownerID, ShareCode: shareCode, AllowPreview: 1, @@ -180,7 +181,7 @@ func (s *ShareService) toShareVO(share *model.Share) *dto.ShareVO { publicURL = strings.TrimRight(publicURL, "/") return &dto.ShareVO{ - ID: share.ID, + ID: dto.StringID(share.ID), ShareCode: share.ShareCode, ShareURL: publicURL + "/s/" + share.ShareCode, HasPassword: share.Password != "", diff --git a/server/service/storage_assign_service.go b/server/service/storage_assign_service.go index b8e71fa..b32a82e 100644 --- a/server/service/storage_assign_service.go +++ b/server/service/storage_assign_service.go @@ -21,11 +21,13 @@ type UserStorageInfo struct { PolicyID uint64 // 当前使用的策略ID(最高优先级) PolicyName string PolicyType string - TotalQuota int64 // 总配额(所有策略叠加) - UsedStorage int64 // 已用空间 - UsedPercent float64 - AccessMode string - Assignments []model.StorageAssignment // 所有关联的分配规则 + TotalQuota int64 // 总配额(所有策略叠加) + UsedStorage int64 // 已用空间 + UsedPercent float64 + AccessMode string + MaxFileSize int64 // 单文件大小上限(字节), 0=不限制 + ChunkThreshold int64 // 分片上传阈值(字节) + Assignments []model.StorageAssignment // 所有关联的分配规则 } // ResolvePolicyID 确定用户上传时应使用的存储策略 @@ -97,9 +99,47 @@ func (s *StorageAssignService) GetUserStorageInfo(userID uint64) (*UserStorageIn info.UsedPercent = float64(info.UsedStorage) / float64(info.TotalQuota) * 100 } + // 根据角色确定文件大小限制 + info.MaxFileSize, info.ChunkThreshold = s.getFileSizeLimit(userID) + return info, nil } +// getFileSizeLimit 根据用户角色返回单文件大小限制和分片阈值 +// 返回: maxFileSize(字节), chunkThreshold(字节) +func (s *StorageAssignService) getFileSizeLimit(userID uint64) (int64, int64) { + // 获取用户角色 + var userRoles []model.UserRole + config.DB.Where("user_id = ?", userID).Find(&userRoles) + + roleIDs := make([]uint64, 0, len(userRoles)) + for _, ur := range userRoles { + roleIDs = append(roleIDs, ur.RoleID) + } + + // 检查角色名称确定限制 + var roles []model.Role + if len(roleIDs) > 0 { + config.DB.Where("id IN ?", roleIDs).Find(&roles) + } + + for _, r := range roles { + switch r.Name { + case "admin": + return 0, 100 * 1024 * 1024 // 不限大小, 100MB分片 + case "staff": + return 500 * 1024 * 1024, 50 * 1024 * 1024 // 500MB上限, 50MB分片 + case "user": + return 100 * 1024 * 1024, 10 * 1024 * 1024 // 100MB上限, 10MB分片 + case "guest": + return 0, 0 // 不允许上传 + } + } + + // 默认 + return 100 * 1024 * 1024, 10 * 1024 * 1024 +} + // getUserAssignments 获取用户的所有存储分配(去重+排序) func (s *StorageAssignService) getUserAssignments(userID uint64) []model.StorageAssignment { var all []model.StorageAssignment diff --git a/server/sql/init.sql b/server/sql/init.sql deleted file mode 100644 index 6392bf7..0000000 --- a/server/sql/init.sql +++ /dev/null @@ -1,222 +0,0 @@ --- ============================================= --- Cloudreve-like 文件管理系统 - 数据库初始化脚本 --- 包含: 文件管理 + RBAC权限 + SSO单点登录 --- ============================================= - -CREATE DATABASE IF NOT EXISTS seeyon_fs DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -USE seeyon_fs; - --- ========== 用户与权限 ========== - --- 用户表 -CREATE TABLE IF NOT EXISTS fs_user ( - id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', - username VARCHAR(64) UNIQUE NOT NULL COMMENT '用户名', - password VARCHAR(256) NOT NULL COMMENT '密码(bcrypt), SSO用户为_SSO_NO_PASSWORD_', - email VARCHAR(128) COMMENT '邮箱', - nickname VARCHAR(64) COMMENT '昵称', - avatar VARCHAR(512) COMMENT '头像URL', - role VARCHAR(32) DEFAULT 'user' COMMENT '旧版角色字段(兼容)', - status TINYINT DEFAULT 1 COMMENT '状态: 1=启用 0=禁用', - storage_quota BIGINT DEFAULT 10737418240 COMMENT '存储配额(字节), 默认10GB', - used_storage BIGINT DEFAULT 0 COMMENT '已用存储(字节)', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; - --- 角色表 (RBAC) -CREATE TABLE IF NOT EXISTS fs_role ( - id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '角色ID', - name VARCHAR(64) UNIQUE NOT NULL COMMENT '角色标识: admin/editor/viewer', - display_name VARCHAR(64) NOT NULL COMMENT '显示名称', - description VARCHAR(256) COMMENT '角色描述', - is_system TINYINT DEFAULT 0 COMMENT '是否系统内置: 1=是(不可删除) 0=否', - status TINYINT DEFAULT 1 COMMENT '状态: 1=启用 0=禁用', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表'; - --- 权限表 (RBAC) -CREATE TABLE IF NOT EXISTS fs_permission ( - id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '权限ID', - code VARCHAR(64) UNIQUE NOT NULL COMMENT '权限标识: file:upload, user:manage', - name VARCHAR(64) NOT NULL COMMENT '显示名称', - resource VARCHAR(32) NOT NULL COMMENT '资源类型: file/folder/share/user/system', - action VARCHAR(32) NOT NULL COMMENT '操作类型: create/read/update/delete/manage', - description VARCHAR(256) COMMENT '权限描述' -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='权限表'; - --- 角色-权限关联表 -CREATE TABLE IF NOT EXISTS fs_role_permission ( - role_id BIGINT NOT NULL COMMENT '角色ID', - permission_id BIGINT NOT NULL COMMENT '权限ID', - PRIMARY KEY (role_id, permission_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色权限关联表'; - --- 用户-角色关联表 -CREATE TABLE IF NOT EXISTS fs_user_role ( - user_id BIGINT NOT NULL COMMENT '用户ID', - role_id BIGINT NOT NULL COMMENT '角色ID', - PRIMARY KEY (user_id, role_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户角色关联表'; - --- ========== SSO 单点登录 ========== - --- SSO提供商配置表 -CREATE TABLE IF NOT EXISTS fs_sso_provider ( - id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '提供商ID', - name VARCHAR(64) NOT NULL COMMENT '提供商标识', - type VARCHAR(32) NOT NULL COMMENT '协议类型: oauth2/oidc/cas/ldap', - display_name VARCHAR(64) NOT NULL COMMENT '登录页显示名称', - icon VARCHAR(512) COMMENT '图标URL', - config JSON COMMENT '协议配置(ClientID/Secret/URL等)', - auto_create TINYINT DEFAULT 1 COMMENT '自动创建用户: 1=是 0=否', - default_role VARCHAR(32) DEFAULT 'viewer' COMMENT '自动创建时的默认角色', - status TINYINT DEFAULT 1 COMMENT '状态: 1=启用 0=禁用', - sort_order INT DEFAULT 0 COMMENT '排序', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='SSO提供商配置表'; - --- SSO会话记录表 -CREATE TABLE IF NOT EXISTS fs_sso_session ( - id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '会话ID', - user_id BIGINT NOT NULL COMMENT '关联的本地用户ID', - provider_id BIGINT NOT NULL COMMENT 'SSO提供商ID', - external_id VARCHAR(256) NOT NULL COMMENT '外部系统用户ID', - access_token VARCHAR(1024) COMMENT 'SSO Access Token', - refresh_token VARCHAR(1024) COMMENT 'SSO Refresh Token', - expires_at DATETIME COMMENT 'Token过期时间', - last_login_at DATETIME COMMENT '最近登录时间', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - INDEX idx_user (user_id), - INDEX idx_provider_external (provider_id, external_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='SSO会话记录表'; - --- ========== 文件管理 ========== - --- 文件夹表 -CREATE TABLE IF NOT EXISTS fs_folder ( - id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '文件夹ID', - name VARCHAR(256) NOT NULL COMMENT '文件夹名称', - parent_id BIGINT DEFAULT 0 COMMENT '父文件夹ID, 0=根目录', - owner_id BIGINT NOT NULL COMMENT '所属用户ID', - path VARCHAR(1024) NOT NULL COMMENT '物化路径, 如 /0/1/5/', - status TINYINT DEFAULT 1 COMMENT '状态: 1=正常 0=回收站', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX idx_parent (parent_id), - INDEX idx_owner (owner_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件夹表'; - --- 文件表 -CREATE TABLE IF NOT EXISTS fs_file ( - id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '文件ID', - name VARCHAR(256) NOT NULL COMMENT '文件名', - extension VARCHAR(32) COMMENT '文件扩展名', - folder_id BIGINT NOT NULL COMMENT '所属文件夹ID', - owner_id BIGINT NOT NULL COMMENT '所属用户ID', - size BIGINT DEFAULT 0 COMMENT '文件大小(字节)', - md5 VARCHAR(32) COMMENT '文件MD5指纹', - storage_key VARCHAR(512) NOT NULL COMMENT '存储引擎中的唯一标识', - mime_type VARCHAR(128) COMMENT 'MIME类型', - is_favorite TINYINT DEFAULT 0 COMMENT '是否收藏: 1=是 0=否', - status TINYINT DEFAULT 1 COMMENT '状态: 1=正常 0=回收站', - download_count INT DEFAULT 0 COMMENT '下载次数', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX idx_folder (folder_id), - INDEX idx_owner (owner_id), - INDEX idx_md5 (md5) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表'; - --- 分享表 -CREATE TABLE IF NOT EXISTS fs_share ( - id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '分享ID', - file_id BIGINT NOT NULL COMMENT '文件ID', - owner_id BIGINT NOT NULL COMMENT '分享者用户ID', - share_code VARCHAR(32) UNIQUE NOT NULL COMMENT '分享码', - password VARCHAR(64) COMMENT '提取密码', - expire_at DATETIME COMMENT '过期时间, NULL=永不过期', - max_download INT DEFAULT 0 COMMENT '最大下载次数, 0=不限制', - download_count INT DEFAULT 0 COMMENT '已下载次数', - permissions VARCHAR(32) DEFAULT 'preview,download' COMMENT '权限', - status TINYINT DEFAULT 1 COMMENT '状态: 1=有效 0=已取消', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分享表'; - --- 存储策略表 -CREATE TABLE IF NOT EXISTS fs_storage_policy ( - id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '策略ID', - name VARCHAR(64) NOT NULL COMMENT '策略名称', - type VARCHAR(32) NOT NULL COMMENT '类型: local/minio/s3/oss', - config JSON COMMENT '配置JSON', - status TINYINT DEFAULT 1 COMMENT '状态: 1=启用 0=禁用', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='存储策略表'; - --- 业务文件关联表 (兼容OA系统) -CREATE TABLE IF NOT EXISTS fs_file_association ( - id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '关联ID', - biz_id VARCHAR(128) NOT NULL COMMENT '业务单据ID', - biz_type VARCHAR(64) NOT NULL COMMENT '业务类型', - file_id BIGINT NOT NULL COMMENT '文件ID', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - INDEX idx_biz (biz_id, biz_type) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务文件关联表'; - --- ========== 初始数据 ========== - --- 插入默认管理员 (密码: admin123) -INSERT INTO fs_user (username, password, nickname, role, storage_quota) -VALUES ('admin', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '管理员', 'admin', 107374182400) -ON DUPLICATE KEY UPDATE id=id; - --- 插入默认本地存储策略 -INSERT INTO fs_storage_policy (name, type, config) -VALUES ('本地存储', 'local', '{"base_path":"./data/storage"}') -ON DUPLICATE KEY UPDATE id=id; - --- 插入默认权限 -INSERT INTO fs_permission (code, name, resource, action) VALUES - ('file:create', '创建文件/文件夹', 'file', 'create'), - ('file:read', '查看文件', 'file', 'read'), - ('file:update', '修改文件', 'file', 'update'), - ('file:delete', '删除文件', 'file', 'delete'), - ('file:upload', '上传文件', 'file', 'upload'), - ('share:create', '创建分享', 'share', 'create'), - ('share:manage', '管理分享', 'share', 'manage'), - ('user:manage', '用户管理', 'user', 'manage'), - ('role:manage', '角色管理', 'role', 'manage'), - ('sso:manage', 'SSO管理', 'sso', 'manage'), - ('system:manage', '系统管理', 'system', 'manage') -ON DUPLICATE KEY UPDATE id=id; - --- 插入默认角色 -INSERT INTO fs_role (name, display_name, description, is_system) VALUES - ('admin', '管理员', '系统管理员, 拥有所有权限', 1), - ('editor', '编辑者', '可以上传、修改、删除文件', 1), - ('viewer', '查看者', '只能查看和下载文件', 1) -ON DUPLICATE KEY UPDATE id=id; - --- 管理员角色分配所有权限 -INSERT INTO fs_role_permission (role_id, permission_id) -SELECT r.id, p.id FROM fs_role r, fs_permission p WHERE r.name = 'admin' -ON DUPLICATE KEY UPDATE role_id=role_id; - --- 编辑者角色分配文件和分享权限 -INSERT INTO fs_role_permission (role_id, permission_id) -SELECT r.id, p.id FROM fs_role r, fs_permission p -WHERE r.name = 'editor' AND p.code IN ('file:create', 'file:read', 'file:update', 'file:delete', 'file:upload', 'share:create') -ON DUPLICATE KEY UPDATE role_id=role_id; - --- 查看者角色分配只读权限 -INSERT INTO fs_role_permission (role_id, permission_id) -SELECT r.id, p.id FROM fs_role r, fs_permission p -WHERE r.name = 'viewer' AND p.code = 'file:read' -ON DUPLICATE KEY UPDATE role_id=role_id; - --- 管理员用户分配admin角色 -INSERT INTO fs_user_role (user_id, role_id) -SELECT u.id, r.id FROM fs_user u, fs_role r WHERE u.username = 'admin' AND r.name = 'admin' -ON DUPLICATE KEY UPDATE user_id=user_id; diff --git a/server/storage/minio.go b/server/storage/minio.go index 73e0c68..ee3546c 100644 --- a/server/storage/minio.go +++ b/server/storage/minio.go @@ -20,9 +20,7 @@ type MinIOEngine struct { } // NewMinIOEngine 创建 MinIO 存储引擎实例 -// endpoint 格式: host:port (如 localhost:9000), 不带 http:// func NewMinIOEngine(endpoint, accessKey, secretKey, bucket string, useSSL bool) (*MinIOEngine, error) { - // 清理 endpoint: 去掉 http:// 或 https:// 前缀 cleanEndpoint := endpoint if len(cleanEndpoint) > 7 && cleanEndpoint[:7] == "http://" { cleanEndpoint = cleanEndpoint[7:] @@ -30,12 +28,10 @@ func NewMinIOEngine(endpoint, accessKey, secretKey, bucket string, useSSL bool) if len(cleanEndpoint) > 8 && cleanEndpoint[:8] == "https://" { cleanEndpoint = cleanEndpoint[8:] } - // 去掉末尾的 / if len(cleanEndpoint) > 0 && cleanEndpoint[len(cleanEndpoint)-1] == '/' { cleanEndpoint = cleanEndpoint[:len(cleanEndpoint)-1] } - // 支持匿名访问(公开桶不需要凭证) var creds *credentials.Credentials if accessKey != "" && secretKey != "" { creds = credentials.NewStaticV4(accessKey, secretKey, "") @@ -54,11 +50,11 @@ func NewMinIOEngine(endpoint, accessKey, secretKey, bucket string, useSSL bool) engine := &MinIOEngine{ Client: client, BucketName: bucket, - Endpoint: endpoint, + Endpoint: cleanEndpoint, UseSSL: useSSL, } - // 有凭证时确保 bucket 存在(匿名访问跳过) + // 有凭证时确保 bucket 存在 ctx := context.Background() if accessKey != "" { exists, checkErr := client.BucketExists(ctx, bucket) @@ -69,7 +65,6 @@ func NewMinIOEngine(endpoint, accessKey, secretKey, bucket string, useSSL bool) if err := client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil { return nil, fmt.Errorf("创建bucket失败: %w", err) } - // 设置 bucket 为公开读 policy := fmt.Sprintf(`{ "Version": "2012-10-17", "Statement": [{ @@ -86,7 +81,7 @@ func NewMinIOEngine(endpoint, accessKey, secretKey, bucket string, useSSL bool) return engine, nil } -// Upload 上传文件到 MinIO +// Upload 上传文件 func (e *MinIOEngine) Upload(storageKey string, reader io.Reader, size int64) error { ctx := context.Background() _, err := e.Client.PutObject(ctx, e.BucketName, storageKey, reader, size, minio.PutObjectOptions{ @@ -98,7 +93,7 @@ func (e *MinIOEngine) Upload(storageKey string, reader io.Reader, size int64) er return nil } -// Download 从 MinIO 下载文件 +// Download 下载文件 func (e *MinIOEngine) Download(storageKey string) (io.ReadCloser, error) { ctx := context.Background() object, err := e.Client.GetObject(ctx, e.BucketName, storageKey, minio.GetObjectOptions{}) @@ -108,22 +103,17 @@ func (e *MinIOEngine) Download(storageKey string) (io.ReadCloser, error) { return object, nil } -// Delete 从 MinIO 删除文件 +// Delete 删除文件 func (e *MinIOEngine) Delete(storageKey string) error { ctx := context.Background() - err := e.Client.RemoveObject(ctx, e.BucketName, storageKey, minio.RemoveObjectOptions{}) - if err != nil { - return fmt.Errorf("MinIO删除失败: %w", err) - } - return nil + return e.Client.RemoveObject(ctx, e.BucketName, storageKey, minio.RemoveObjectOptions{}) } -// Exists 判断文件是否存在于 MinIO +// Exists 检查文件是否存在 func (e *MinIOEngine) Exists(storageKey string) (bool, error) { ctx := context.Background() _, err := e.Client.StatObject(ctx, e.BucketName, storageKey, minio.StatObjectOptions{}) if err != nil { - // 检查是否是"不存在"的错误 errResponse := minio.ToErrorResponse(err) if errResponse.Code == "NoSuchKey" { return false, nil @@ -133,17 +123,16 @@ func (e *MinIOEngine) Exists(storageKey string) (bool, error) { return true, nil } -// GetURL 获取文件的公开访问 URL +// GetURL 获取公开访问URL func (e *MinIOEngine) GetURL(storageKey string) string { scheme := "http" if e.UseSSL { scheme = "https" } - return fmt.Sprintf("%s/%s/%s/%s", scheme+"://"+e.Endpoint, e.BucketName, storageKey, "") + return fmt.Sprintf("%s://%s/%s/%s", scheme, e.Endpoint, e.BucketName, storageKey) } -// GetPresignedURL 生成预签名下载链接(临时授权) -// expiration: 链接有效期 +// GetPresignedURL 生成预签名下载链接 func (e *MinIOEngine) GetPresignedURL(storageKey string, expiration time.Duration) (string, error) { ctx := context.Background() reqParams := make(url.Values) @@ -154,7 +143,23 @@ func (e *MinIOEngine) GetPresignedURL(storageKey string, expiration time.Duratio return presignedURL.String(), nil } -// GetPresignedPutURL 生成预签名上传链接(用于前端直传) +// GetPresignedPreviewURL 生成预签名预览链接(内联展示, 不触发下载) +func (e *MinIOEngine) GetPresignedPreviewURL(storageKey string, mimeType string, expiration time.Duration) (string, error) { + ctx := context.Background() + reqParams := make(url.Values) + // S3协议: 通过response-*参数覆盖响应头, 签名时一并签名 + reqParams.Set("response-content-disposition", "inline") + if mimeType != "" { + reqParams.Set("response-content-type", mimeType) + } + presignedURL, err := e.Client.PresignedGetObject(ctx, e.BucketName, storageKey, expiration, reqParams) + if err != nil { + return "", fmt.Errorf("生成预签名预览URL失败: %w", err) + } + return presignedURL.String(), nil +} + +// GetPresignedPutURL 生成预签名上传链接 func (e *MinIOEngine) GetPresignedPutURL(storageKey string, expiration time.Duration) (string, error) { ctx := context.Background() presignedURL, err := e.Client.PresignedPutObject(ctx, e.BucketName, storageKey, expiration) @@ -164,12 +169,54 @@ func (e *MinIOEngine) GetPresignedPutURL(storageKey string, expiration time.Dura return presignedURL.String(), nil } -// GetPreviewURL 获取文件预览链接 -// 对于图片/文本/PDF等可直接预览的文件, 返回公开URL -func (e *MinIOEngine) GetPreviewURL(storageKey string) string { - scheme := "http" - if e.UseSSL { - scheme = "https" - } - return fmt.Sprintf("%s/%s/%s/%s", scheme+"://"+e.Endpoint, e.BucketName, storageKey, "") +// GetPresignedChunkURL 生成分片预签名上传URL +// chunkKey: 分片的唯一标识, 如 "2026/07/03/xx/chunk_001" +func (e *MinIOEngine) GetPresignedChunkURL(chunkKey string, expiration time.Duration) (string, error) { + return e.GetPresignedPutURL(chunkKey, expiration) +} + +// MergeChunks 合并分片到目标key +// 读取所有分片, 按顺序合并写入目标key +func (e *MinIOEngine) MergeChunks(chunkKeys []string, targetKey string) error { + ctx := context.Background() + + // 创建管道用于流式合并 + pr, pw := io.Pipe() + + go func() { + defer pw.Close() + for _, key := range chunkKeys { + obj, err := e.Client.GetObject(ctx, e.BucketName, key, minio.GetObjectOptions{}) + if err != nil { + pw.CloseWithError(err) + return + } + _, err = io.Copy(pw, obj) + obj.Close() + if err != nil { + pw.CloseWithError(err) + return + } + } + }() + + // 上传合并后的文件(流式, 不需要知道总大小) + _, err := e.Client.PutObject(ctx, e.BucketName, targetKey, pr, -1, minio.PutObjectOptions{ + ContentType: "application/octet-stream", + }) + if err != nil { + return fmt.Errorf("合并上传失败: %w", err) + } + + // 清理分片 + for _, key := range chunkKeys { + e.Client.RemoveObject(ctx, e.BucketName, key, minio.RemoveObjectOptions{}) + } + + return nil +} + +// GetPreviewURL 获取预览链接 +func (e *MinIOEngine) GetPreviewURL(storageKey string) string { + return e.GetURL(storageKey) } diff --git a/server/utils/log_buffer.go b/server/utils/log_buffer.go new file mode 100644 index 0000000..548d564 --- /dev/null +++ b/server/utils/log_buffer.go @@ -0,0 +1,103 @@ +package utils + +import ( + "fmt" + "io" + "os" + "sync" + "time" +) + +// LogEntry 日志条目 +type LogEntry struct { + Time time.Time `json:"time"` + Level string `json:"level"` + Message string `json:"message"` +} + +// LogBuffer 环形日志缓冲区 +type LogBuffer struct { + mu sync.RWMutex + entries []LogEntry + maxSize int + writer io.Writer // 原始输出(控制台) +} + +var LogBuf = NewLogBuffer(2000) + +func NewLogBuffer(maxSize int) *LogBuffer { + return &LogBuffer{ + entries: make([]LogEntry, 0, maxSize), + maxSize: maxSize, + writer: os.Stderr, + } +} + +// Write 实现 io.Writer 接口, 用于 log.SetOutput +func (b *LogBuffer) Write(p []byte) (n int, err error) { + // 写入原始输出 + b.writer.Write(p) + + msg := string(p) + level := "INFO" + switch { + case contains(msg, "FAIL") || contains(msg, "Error") || contains(msg, "error"): + level = "ERROR" + case contains(msg, "WARN") || contains(msg, "warn"): + level = "WARN" + } + + b.mu.Lock() + b.entries = append(b.entries, LogEntry{ + Time: time.Now(), + Level: level, + Message: msg, + }) + // 超过上限则截掉前半部分 + if len(b.entries) > b.maxSize { + b.entries = b.entries[len(b.entries)-b.maxSize:] + } + b.mu.Unlock() + + return len(p), nil +} + +// Get 获取最近 n 条日志 +func (b *LogBuffer) Get(n int) []LogEntry { + b.mu.RLock() + defer b.mu.RUnlock() + + total := len(b.entries) + if n <= 0 || n > total { + n = total + } + result := make([]LogEntry, n) + copy(result, b.entries[total-n:]) + return result +} + +// Count 日志总数 +func (b *LogBuffer) Count() int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.entries) +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && findSubstring(s, sub)) +} + +func findSubstring(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// Logf 写入一条格式化日志到缓冲区 +func Logf(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + LogBuf.Write([]byte(msg + "\n")) +} diff --git a/server/utils/snowflake.go b/server/utils/snowflake.go new file mode 100644 index 0000000..516c40a --- /dev/null +++ b/server/utils/snowflake.go @@ -0,0 +1,60 @@ +package utils + +import ( + "sync" + "time" +) + +// Snowflake 雪花ID生成器 +// 64位布局: 41位时间戳 + 10位机器ID + 12位序列号 +// 生成约19位数字, 前端用字符串表示避免精度丢失 +type Snowflake struct { + mu sync.Mutex + epoch int64 // 自定义纪元(毫秒) + machineID int64 // 机器ID (0-1023) + sequence int64 // 序列号 (0-4095) + lastTime int64 // 上次生成ID的时间戳 +} + +var sf *Snowflake + +func init() { + sf = NewSnowflake(1704067200000, 1) // 2024-01-01 00:00:00 UTC 作为纪元 +} + +func NewSnowflake(epoch int64, machineID int64) *Snowflake { + return &Snowflake{ + epoch: epoch, + machineID: machineID & 0x3FF, // 10位, 最大1023 + } +} + +// NextID 生成下一个唯一ID +func (s *Snowflake) NextID() uint64 { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UnixMilli() - s.epoch + + if now == s.lastTime { + s.sequence = (s.sequence + 1) & 0xFFF // 12位, 最大4095 + if s.sequence == 0 { + // 序列号溢出, 等待下一毫秒 + for now <= s.lastTime { + now = time.Now().UnixMilli() - s.epoch + } + } + } else { + s.sequence = 0 + } + + s.lastTime = now + + // 组装ID: 时间戳左移22位 + 机器ID左移12位 + 序列号 + return uint64(now)<<22 | uint64(s.machineID)<<12 | uint64(s.sequence) +} + +// GenID 全局便捷方法 +func GenID() uint64 { + return sf.NextID() +} diff --git a/web/.env b/web/.env new file mode 100644 index 0000000..a7e5d0b --- /dev/null +++ b/web/.env @@ -0,0 +1,8 @@ +# 开发环境配置 +# 复制此文件为 .env.local 可覆盖本地配置, .env.local 不会被 git 跟踪 + +# 后端 API 地址(开发代理用) +VITE_API_TARGET=http://127.0.0.1:8075 + +# 前端端口 +VITE_PORT=5173 diff --git a/web/.env.production b/web/.env.production new file mode 100644 index 0000000..e0f157f --- /dev/null +++ b/web/.env.production @@ -0,0 +1,5 @@ +# 生产环境配置 +# 构建时自动加载, API 地址通过 Nginx 反向代理, 不需要填完整地址 + +# API 基础路径(与 Nginx proxy_pass 对应) +VITE_API_BASE=/api diff --git a/web/build.bat b/web/build.bat new file mode 100644 index 0000000..01037d4 --- /dev/null +++ b/web/build.bat @@ -0,0 +1,29 @@ +@echo off +chcp 65001 >nul +echo ============================================ +echo webpackup +echo ============================================ + +cd /d "%~dp0" + +echo [1/3] install dependences... +call npm install +if %errorlevel% neq 0 ( + echo intall dependences fail! + exit /b 1 +) + +echo [2/3] build web... +call npm run build +if %errorlevel% neq 0 ( + echo build fail! + exit /b 1 +) + +echo [3/3] success +echo. +echo ============================================ +echo packup success! +echo ============================================ +echo. + diff --git a/web/build.sh b/web/build.sh new file mode 100644 index 0000000..d8b5718 --- /dev/null +++ b/web/build.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# ============================================ +# 前端打包脚本 +# 构建并打包 dist + nginx.conf 为部署包 +# ============================================ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +echo "============================================" +echo " 前端打包" +echo "============================================" + +# 安装依赖 +echo "[1/4] 安装依赖..." +npm install + +# 构建 +echo "[2/4] 构建前端..." +npm run build + +# 复制 nginx 配置到 dist +echo "[3/4] 复制配置文件..." +cp nginx.conf dist/nginx.conf.example + +# 打包 +echo "[4/4] 打包..." +VERSION=$(date +%Y%m%d_%H%M%S) +TAR_NAME="frontend_${VERSION}.tar.gz" +tar -czf "$TAR_NAME" -C dist . + +echo "" +echo "============================================" +echo " 打包完成!" +echo "============================================" +echo "" +echo "部署包: web/$TAR_NAME" +echo "大小: $(du -h "$TAR_NAME" | cut -f1)" +echo "内容: 前端文件 + nginx.conf.example" +echo "" +echo "【部署步骤】" +echo " 1. 上传到服务器: scp $TAR_NAME user@server:/opt/filesystem/" +echo " 2. 解压: mkdir -p /opt/filesystem/frontend && tar -xzf $TAR_NAME -C /opt/filesystem/frontend/" +echo " 3. 复制 Nginx 配置: cp /opt/filesystem/frontend/nginx.conf.example /etc/nginx/conf.d/filesystem.conf" +echo " 4. 修改配置: vi /etc/nginx/conf.d/filesystem.conf (改 server_name、路径等)" +echo " 5. 重启 Nginx: nginx -t && systemctl reload nginx" +echo "" diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..2b264aa --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,35 @@ +server { + listen 80; + server_name localhost; # 改为你的域名或IP + + # 前端静态文件 + location / { + root /opt/filesystem/frontend; # dist 解压目录 + index index.html; + try_files $uri $uri/ /index.html; # SPA 路由支持 + } + + # API 反向代理到后端 + location /api/ { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # 上传大文件支持 + client_max_body_size 200m; + } + + # 文件 CDN (本地存储时使用, 对应 config.yaml 中 cdn_path_prefix) + location /public/ { + alias /opt/filesystem/server/data/storage/; # 本地存储目录 + expires 7d; + add_header Cache-Control "public, immutable"; + add_header Access-Control-Allow-Origin *; + } + + # gzip 压缩 + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; + gzip_min_length 1024; +} diff --git a/web/package-lock.json b/web/package-lock.json index efb59cb..42807fc 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -18,7 +18,7 @@ "@vitejs/plugin-vue": "^5.0.3", "typescript": "^5.3.3", "vite": "^5.0.11", - "vue-tsc": "^1.8.27" + "vue-tsc": "^3.3.7" } }, "node_modules/@babel/helper-string-parser": { @@ -911,34 +911,32 @@ } }, "node_modules/@volar/language-core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", - "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", "dev": true, "license": "MIT", "dependencies": { - "@volar/source-map": "1.11.1" + "@volar/source-map": "2.4.28" } }, "node_modules/@volar/source-map": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", - "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", "dev": true, - "license": "MIT", - "dependencies": { - "muggle-string": "^0.3.1" - } + "license": "MIT" }, "node_modules/@volar/typescript": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", - "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "1.11.1", - "path-browserify": "^1.0.1" + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" } }, "node_modules/@vue/compiler-core": { @@ -998,29 +996,19 @@ "license": "MIT" }, "node_modules/@vue/language-core": { - "version": "1.8.27", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", - "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "version": "3.3.7", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.7.tgz", + "integrity": "sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "~1.11.1", - "@volar/source-map": "~1.11.1", - "@vue/compiler-dom": "^3.3.0", - "@vue/shared": "^3.3.0", - "computeds": "^0.0.1", - "minimatch": "^9.0.3", - "muggle-string": "^0.3.1", + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", - "vue-template-compiler": "^2.7.14" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "picomatch": "^4.0.4" } }, "node_modules/@vue/reactivity": { @@ -1123,6 +1111,13 @@ "node": ">= 6.0.0" } }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, "node_modules/async-validator": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", @@ -1147,23 +1142,6 @@ "proxy-from-env": "^2.1.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1189,13 +1167,6 @@ "node": ">= 0.8" } }, - "node_modules/computeds": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", - "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", - "dev": true, - "license": "MIT" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1208,13 +1179,6 @@ "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT" }, - "node_modules/de-indent": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", - "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", - "dev": true, - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1531,16 +1495,6 @@ "node": ">= 0.4" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -1622,22 +1576,6 @@ "node": ">= 0.6" } }, - "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1645,9 +1583,9 @@ "license": "MIT" }, "node_modules/muggle-string": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", - "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", "dev": true, "license": "MIT" }, @@ -1677,7 +1615,7 @@ }, "node_modules/path-browserify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "dev": true, "license": "MIT" @@ -1688,6 +1626,19 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pinia": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", @@ -1792,19 +1743,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1888,6 +1826,13 @@ } } }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vue": { "version": "3.5.39", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", @@ -1956,33 +1901,21 @@ "vue": "^3.5.0" } }, - "node_modules/vue-template-compiler": { - "version": "2.7.16", - "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", - "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "de-indent": "^1.0.2", - "he": "^1.2.0" - } - }, "node_modules/vue-tsc": { - "version": "1.8.27", - "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", - "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "version": "3.3.7", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.7.tgz", + "integrity": "sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ==", "dev": true, "license": "MIT", "dependencies": { - "@volar/typescript": "~1.11.1", - "@vue/language-core": "1.8.27", - "semver": "^7.5.4" + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.7" }, "bin": { "vue-tsc": "bin/vue-tsc.js" }, "peerDependencies": { - "typescript": "*" + "typescript": ">=5.0.0" } } } diff --git a/web/package.json b/web/package.json index 3b48d05..6a88c80 100644 --- a/web/package.json +++ b/web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vue-tsc && vite build", + "build": "vite build", "preview": "vite preview" }, "dependencies": { @@ -19,6 +19,6 @@ "@vitejs/plugin-vue": "^5.0.3", "typescript": "^5.3.3", "vite": "^5.0.11", - "vue-tsc": "^1.8.27" + "vue-tsc": "^3.3.7" } } diff --git a/web/public/config.json b/web/public/config.json new file mode 100644 index 0000000..2614b22 --- /dev/null +++ b/web/public/config.json @@ -0,0 +1,5 @@ +{ + "apiTarget": "", + "title": "文件管理系统", + "pageSize": 50 +} diff --git a/web/src/api/file.ts b/web/src/api/file.ts index 6bd5fa7..808a70d 100644 --- a/web/src/api/file.ts +++ b/web/src/api/file.ts @@ -5,43 +5,56 @@ import request from './request' /** 文件信息 */ export interface FileInfo { - id: number + id: string name: string extension: string - folder_id: number + folder_id: string size: number + storage_key: string + storage_policy_id: number mime_type: string is_favorite: number download_count: number + version: number + download_url: string + preview_url: string created_at: string updated_at: string } /** 文件夹信息 */ export interface FolderInfo { - id: number + id: string + biz_id: string name: string - parent_id: number + parent_id: string path: string + version: number created_at: string updated_at: string } -/** 文件列表响应 */ +/** 文件列表响应(分页) */ export interface FileListResult { folders: FolderInfo[] files: FileInfo[] + total_files: number + total_folders: number + page: number + page_size: number } /** 分享信息 */ export interface ShareInfo { - id: number + id: string share_code: string share_url: string has_password: boolean expire_at: string | null max_download: number download_count: number + allow_preview: number + allow_download: number created_at: string } @@ -50,7 +63,7 @@ export interface ShareInfo { * @param file 文件对象 * @param folderId 目标文件夹ID, 0=根目录 */ -export function uploadFile(file: File, folderId: number = 0) { +export function uploadFile(file: File, folderId: string = '0') { const formData = new FormData() formData.append('file', file) return request.post(`/file/upload?folder_id=${folderId}`, formData, { @@ -58,52 +71,137 @@ export function uploadFile(file: File, folderId: number = 0) { }) } +/** 存储信息 */ +export interface StorageInfo { + policy_id: number + policy_name: string + policy_type: string + storage_quota: number + used_storage: number + used_percent: number + max_file_size: number + chunk_threshold: number +} + +/** 分片上传初始化结果 */ +export interface ChunkInitResult { + upload_id: string + instant: boolean + file_id: number + chunk_size: number + total: number +} + /** - * 获取文件夹内容(文件+子文件夹) + * 获取用户存储信息(含文件大小限制) */ -export function getFolderChildren(folderId: number | string) { - return request.get(`/folder/${folderId}/children`) +export function getStorageInfo() { + return request.get('/user/storage-info') +} + +/** + * 初始化分片上传 + */ +export function initChunkUpload(data: { file_name: string; file_size: number; md5?: string; folder_id?: string }) { + return request.post('/file/upload/init', data) +} + +/** + * 上传单个分片(本地模式) + */ +export function uploadChunk(uploadId: string, index: number, chunk: Blob) { + const formData = new FormData() + formData.append('chunk', chunk, 'chunk') + return request.put(`/file/upload/chunk/${uploadId}/${index}`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) +} + +/** + * 确认MinIO分片上传完成(直传模式) + */ +export function confirmChunk(uploadId: string, index: number, etag: string) { + return request.post(`/file/upload/chunk/${uploadId}/${index}/confirm`, { etag }) +} + +/** + * 合并分片完成上传 + */ +export function mergeChunks(uploadId: string) { + return request.post(`/file/upload/merge/${uploadId}`) +} + +/** + * 查询上传进度 + */ +export function getUploadProgress(uploadId: string) { + return request.get(`/file/upload/progress/${uploadId}`) +} + +/** + * 取消上传 + */ +export function cancelUpload(uploadId: string) { + return request.delete(`/file/upload/${uploadId}`) +} + +/** + * 获取文件夹内容(文件+子文件夹, 支持分页) + */ +export function getFolderChildren(folderId: string, page: number = 1, pageSize: number = 50) { + return request.get(`/folder/${folderId}/children`, { + params: { page, page_size: pageSize }, + }) } /** * 创建文件夹 */ -export function createFolder(name: string, parentId: number = 0) { - return request.post('/folder', { name, parent_id: parentId }) +export function createFolder(name: string, parentId: string = '0', bizId?: string) { + const data: any = { name, parent_id: parentId } + if (bizId) data.biz_id = bizId + return request.post('/folder', data) +} + +/** + * 根据业务ID获取文件夹信息 + */ +export function getFolderByBizId(bizId: string) { + return request.get(`/folder/by-bizid/${encodeURIComponent(bizId)}`) } /** * 重命名文件 */ -export function renameFile(fileId: number, newName: string) { +export function renameFile(fileId: string, newName: string) { return request.put(`/file/${fileId}/rename`, { new_name: newName }) } /** * 重命名文件夹 */ -export function renameFolder(folderId: number, newName: string) { +export function renameFolder(folderId: string, newName: string) { return request.put(`/folder/${folderId}/rename`, { new_name: newName }) } /** * 删除文件(移入回收站) */ -export function deleteFile(fileId: number) { +export function deleteFile(fileId: string) { return request.delete(`/file/${fileId}`) } /** * 删除文件夹(移入回收站) */ -export function deleteFolder(folderId: number) { +export function deleteFolder(folderId: string) { return request.delete(`/folder/${folderId}`) } /** * 移动文件 */ -export function moveFile(fileId: number, targetFolderId: number) { +export function moveFile(fileId: string, targetFolderId: string) { return request.post(`/file/${fileId}/move`, { target_folder_id: targetFolderId }) } @@ -124,28 +222,28 @@ export function getTrash() { /** * 恢复文件 */ -export function restoreFile(fileId: number) { +export function restoreFile(fileId: string) { return request.post(`/file/${fileId}/restore`) } /** * 永久删除文件 */ -export function permanentDeleteFile(fileId: number) { +export function permanentDeleteFile(fileId: string) { return request.delete(`/file/${fileId}/permanent`) } /** * 获取文件下载链接 */ -export function getDownloadUrl(fileId: number): string { +export function getDownloadUrl(fileId: string): string { return `/api/file/${fileId}/download` } /** * 创建分享 */ -export function createShare(fileId: number, password?: string, expireHours?: number, maxDownload?: number, allowPreview?: number, allowDownload?: number) { +export function createShare(fileId: string, password?: string, expireHours?: number, maxDownload?: number, allowPreview?: number, allowDownload?: number) { return request.post('/share', { file_id: fileId, password, @@ -166,7 +264,7 @@ export function getMyShares() { /** * 取消分享 */ -export function cancelShare(shareId: number) { +export function cancelShare(shareId: string) { return request.delete(`/share/${shareId}`) } @@ -177,6 +275,13 @@ export function getShareInfo(code: string) { return request.get(`/s/${code}`) } +/** + * 获取文件授权列表 + */ +export function getFilePermissions(fileId: string) { + return request.get }>(`/file/${fileId}/grant`) +} + /** * 验证分享密码 */ diff --git a/web/src/api/request.ts b/web/src/api/request.ts index e8cafe5..2956415 100644 --- a/web/src/api/request.ts +++ b/web/src/api/request.ts @@ -5,10 +5,12 @@ import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' +import { getConfig } from '../config' // 创建 axios 实例 +const cfg = getConfig() const request = axios.create({ - baseURL: '/api', + baseURL: cfg.apiTarget ? `${cfg.apiTarget}/api` : '/api', timeout: 30000, }) @@ -20,7 +22,7 @@ request.interceptors.request.use( (config) => { const token = localStorage.getItem('token') if (token) { - config.headers.Authorization = `Bearer ${token}` + config.headers.token = token } return config }, diff --git a/web/src/config.ts b/web/src/config.ts new file mode 100644 index 0000000..e8ae0e1 --- /dev/null +++ b/web/src/config.ts @@ -0,0 +1,39 @@ +/** + * 运行时配置 + * 部署后可直接修改 config.json, 无需重新构建 + */ + +interface AppConfig { + apiTarget: string // 后端地址, 如 http://127.0.0.1:8075 (留空则用相对路径 /api) + title: string // 系统标题 + pageSize: number // 文件列表每页条数 +} + +let config: AppConfig = { + apiTarget: '', + title: '文件管理系统', + pageSize: 50, +} + +let loaded = false + +/** 加载运行时配置 */ +export async function loadConfig(): Promise { + if (loaded) return config + try { + const resp = await fetch('/config.json?t=' + Date.now()) + if (resp.ok) { + const remote = await resp.json() + config = { ...config, ...remote } + } + } catch (e) { + console.warn('加载 config.json 失败, 使用默认配置') + } + loaded = true + return config +} + +/** 获取配置 */ +export function getConfig(): AppConfig { + return config +} diff --git a/web/src/main.ts b/web/src/main.ts index 059ab71..b2a6cac 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -11,13 +11,17 @@ import './assets/styles/global.css' import App from './App.vue' import router from './router' +import { loadConfig } from './config' -const app = createApp(App) +// 先加载运行时配置, 再启动应用 +loadConfig().then(() => { + const app = createApp(App) -app.use(createPinia()) // 状态管理 -app.use(router) // 路由 -app.use(ElementPlus, { // UI组件库(中文) - locale: zhCn, + app.use(createPinia()) // 状态管理 + app.use(router) // 路由 + app.use(ElementPlus, { // UI组件库(中文) + locale: zhCn, + }) + + app.mount('#app') }) - -app.mount('#app') diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 4b812b2..7300091 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -27,6 +27,18 @@ const router = createRouter({ component: () => import('../views/ShareView.vue'), meta: { requiresAuth: false }, }, + { + path: '/trash', + name: 'TrashView', + component: () => import('../views/TrashView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/shares', + name: 'SharesView', + component: () => import('../views/SharesView.vue'), + meta: { requiresAuth: true }, + }, // ====== 后台(管理员) ====== { @@ -44,6 +56,7 @@ const router = createRouter({ { path: 'storage-assign', name: 'AdminStorageAssign', component: () => import('../views/admin/StorageAssignManage.vue') }, { path: 'open-platform', name: 'AdminOpenPlatform', component: () => import('../views/admin/OpenPlatform.vue') }, { path: 'logs', name: 'AdminLogs', component: () => import('../views/admin/LogMonitor.vue') }, + { path: 'system-log', name: 'AdminSystemLog', component: () => import('../views/admin/SystemLog.vue') }, { path: 'blacklist', name: 'AdminBlacklist', component: () => import('../views/admin/IPBlacklist.vue') }, { path: 'backup', name: 'AdminBackup', component: () => import('../views/admin/BackupManage.vue') }, ], diff --git a/web/src/stores/file.ts b/web/src/stores/file.ts index db280c1..6aa48f5 100644 --- a/web/src/stores/file.ts +++ b/web/src/stores/file.ts @@ -8,16 +8,16 @@ import { getFolderChildren, type FileListResult, type FileInfo, type FolderInfo /** 面包屑路径项 */ export interface PathItem { - id: number + id: string name: string } export const useFileStore = defineStore('file', () => { - // 当前文件夹ID (0=根目录) - const currentFolderId = ref(0) + // 当前文件夹ID ('0'=根目录) + const currentFolderId = ref('0') // 面包屑路径 - const breadcrumbs = ref([{ id: 0, name: '根目录' }]) + const breadcrumbs = ref([{ id: '0', name: '根目录' }]) // 文件列表数据 const folders = ref([]) @@ -26,18 +26,33 @@ export const useFileStore = defineStore('file', () => { // 加载状态 const loading = ref(false) + // 分页状态 + const page = ref(1) + const pageSize = ref(50) + const totalFiles = ref(0) + const totalFolders = ref(0) + /** * 加载指定文件夹的内容 */ - async function loadFolder(folderId: number = 0) { + async function loadFolder(folderId: string = '0', pageNum?: number) { loading.value = true try { - const res = await getFolderChildren(folderId === 0 ? 'root' : folderId) + if (pageNum !== undefined) { + page.value = pageNum + } + const res = await getFolderChildren( + folderId === '0' ? 'root' : folderId, + page.value, + pageSize.value, + ) const data: FileListResult = res.data currentFolderId.value = folderId folders.value = data.folders || [] files.value = data.files || [] + totalFiles.value = data.total_files || 0 + totalFolders.value = data.total_folders || 0 } catch (error) { console.error('加载文件列表失败:', error) } finally { @@ -45,12 +60,30 @@ export const useFileStore = defineStore('file', () => { } } + /** + * 切换页码 + */ + function changePage(newPage: number) { + page.value = newPage + loadFolder(currentFolderId.value) + } + + /** + * 切换每页大小 + */ + function changePageSize(newSize: number) { + pageSize.value = newSize + page.value = 1 + loadFolder(currentFolderId.value) + } + /** * 进入子文件夹 * 更新面包屑路径 */ - function enterFolder(folderId: number, folderName: string) { + function enterFolder(folderId: string, folderName: string) { breadcrumbs.value.push({ id: folderId, name: folderName }) + page.value = 1 loadFolder(folderId) } @@ -60,6 +93,7 @@ export const useFileStore = defineStore('file', () => { function navigateTo(index: number) { const item = breadcrumbs.value[index] breadcrumbs.value = breadcrumbs.value.slice(0, index + 1) + page.value = 1 loadFolder(item.id) } @@ -67,8 +101,9 @@ export const useFileStore = defineStore('file', () => { * 返回根目录 */ function goRoot() { - breadcrumbs.value = [{ id: 0, name: '根目录' }] - loadFolder(0) + breadcrumbs.value = [{ id: '0', name: '根目录' }] + page.value = 1 + loadFolder('0') } /** @@ -84,7 +119,13 @@ export const useFileStore = defineStore('file', () => { folders, files, loading, + page, + pageSize, + totalFiles, + totalFolders, loadFolder, + changePage, + changePageSize, enterFolder, navigateTo, goRoot, diff --git a/web/src/views/FileManager.vue b/web/src/views/FileManager.vue index 57c7580..01757e6 100644 --- a/web/src/views/FileManager.vue +++ b/web/src/views/FileManager.vue @@ -11,9 +11,6 @@ - @@ -82,7 +79,7 @@
- + 返回上一级 @@ -158,6 +155,11 @@ + + + + +
@@ -175,6 +177,19 @@
{{ row.isFolder ? '' : formatSize(row.size) }}
+ + +
+ +
@@ -196,8 +211,15 @@ - + + + + + + + + + + + + + +
{{ infoData.name }}
+
+ {{ infoData.id }} + {{ infoData.owner_name || '-' }} + + {{ infoData.extension ? infoData.extension.toUpperCase() + ' 文件' : '-' }} + + + {{ formatSize(infoData.size) }} + + {{ infoData.mime_type || '-' }} + {{ infoData.storage_policy_id }} + {{ infoData.download_count || 0 }} + {{ infoData.version || 1 }} + {{ formatDate(infoData.created_at) }} + {{ formatDate(infoData.updated_at) }} +
+ + +
+
授权列表
+ + + + + + + +
+
@@ -287,15 +346,17 @@ import { useRoute } from 'vue-router' import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus' import { Refresh, Search, Upload, FolderAdd, Folder, Document, UploadFilled, - HomeFilled, Share, Delete, Download, List, Grid, Files, Back, Setting, Edit, + HomeFilled, Share, Delete, Download, List, Grid, Files, Back, Setting, Edit, InfoFilled, } from '@element-plus/icons-vue' import { useUserStore } from '../stores/user' import { useFileStore } from '../stores/file' import request from '../api/request' import { uploadFile, createFolder, deleteFile, deleteFolder, - getDownloadUrl, createShare, searchFiles, - type FileInfo, type ShareInfo, + getDownloadUrl, createShare, searchFiles, getTrash, restoreFile, permanentDeleteFile, + getMyShares, cancelShare, getFilePermissions, + getStorageInfo, initChunkUpload, uploadChunk, confirmChunk, mergeChunks, + type FileInfo, type ShareInfo, type StorageInfo, type ChunkInitResult, } from '../api/file' const userStore = useUserStore() @@ -304,6 +365,8 @@ const route = useRoute() const viewMode = ref<'list' | 'grid'>('list') const searchKeyword = ref('') +const currentPage = ref(1) +const currentPageSize = ref(50) const searchResults = ref([]) const isSearching = ref(false) @@ -314,17 +377,24 @@ const uploadProgress = ref(0) const showCreateFolderDialog = ref(false) const newFolderName = ref('') +const newFolderBizId = ref('') const showShareDialog = ref(false) const shareTarget = ref(null) const shareForm = ref({ password: '', expireHours: 0, maxDownload: 0, allowPreview: true, allowDownload: true }) const shareResult = ref(null) + // 修改密码 const showPasswordDialog = ref(false) const changingPassword = ref(false) const passwordForm = ref({ old_password: '', new_password: '', confirm_password: '' }) +// 详情 +const showInfoDialog = ref(false) +const infoData = ref({}) +const infoPerms = ref([]) + // 重命名 const showRenameDialog = ref(false) const renameTarget = ref(null) @@ -338,16 +408,20 @@ const tableData = computed(() => { ] }) -const storageInfo = ref({ +const storageInfo = ref({ + policy_id: 0, policy_name: '', + policy_type: '', storage_quota: 0, used_storage: 0, used_percent: 0, + max_file_size: 0, + chunk_threshold: 0, }) onMounted(() => { if (userStore.token) { - fileStore.loadFolder(0) + fileStore.loadFolder('0') userStore.loadPermissions() loadStorageInfo() } @@ -360,7 +434,7 @@ onMounted(() => { async function loadStorageInfo() { try { - const res = await request.get('/user/storage-info') as any + const res = await getStorageInfo() storageInfo.value = res.data || storageInfo.value } catch (e) {} } @@ -401,7 +475,7 @@ async function handleDownload(file: FileInfo) { try { const token = localStorage.getItem('token') const res = await fetch(`/api/file/${file.id}/download`, { - headers: { 'Authorization': `Bearer ${token}` } + headers: { token } }) if (!res.ok) { const err = await res.json().catch(() => ({})) @@ -429,31 +503,116 @@ async function handleSearch() { } function handleClearSearch() { isSearching.value = false; searchResults.value = [] } +function handlePageChange(newPage: number) { + currentPage.value = newPage + fileStore.changePage(newPage) +} +function handleSizeChange(newSize: number) { + currentPageSize.value = newSize + currentPage.value = 1 + fileStore.changePageSize(newSize) +} + function handleUploadChange(file: UploadFile) { uploadFiles.value.push(file) } function handleUploadRemove(file: UploadFile) { uploadFiles.value = uploadFiles.value.filter(f => f.uid !== file.uid) } async function handleUpload() { if (!uploadFiles.value.length) { ElMessage.warning('请选择文件'); return } + + // 校验文件大小 + const maxSize = storageInfo.value.max_file_size + for (const item of uploadFiles.value) { + if (maxSize > 0 && item.raw!.size > maxSize) { + ElMessage.error(`文件 ${item.name} 大小 ${formatSize(item.raw!.size)} 超过限制 ${formatSize(maxSize)}`) + return + } + } + uploading.value = true uploadProgress.value = 0 let ok = 0 + for (const item of uploadFiles.value) { - try { await uploadFile(item.raw!, fileStore.currentFolderId); ok++ } catch (e) {} + try { + const file = item.raw! + const threshold = storageInfo.value.chunk_threshold || 50 * 1024 * 1024 + + if (file.size > threshold) { + // 大文件: 分片上传 + await chunkedUpload(file, fileStore.currentFolderId) + } else { + // 小文件: 普通上传 + await uploadFile(file, fileStore.currentFolderId) + } + ok++ + } catch (e: any) { + ElMessage.error(`上传失败: ${e.message || item.name}`) + } uploadProgress.value++ } + uploading.value = false showUploadDialog.value = false uploadFiles.value = [] if (ok > 0) { ElMessage.success(`成功上传 ${ok} 个文件`); fileStore.refresh() } } +/** 分片上传(自动选择本地或MinIO模式) */ +async function chunkedUpload(file: File, folderId: string) { + // 1. 初始化 + const initRes = await initChunkUpload({ + file_name: file.name, + file_size: file.size, + folder_id: folderId, + }) + const data = initRes.data as ChunkInitResult + const { upload_id, instant, chunk_size, total, mode } = data + + // 秒传 + if (instant) { + ElMessage.success(`${file.name} 秒传成功`) + return + } + + if (mode === 'minio' && data.parts) { + // MinIO直传模式: 客户端直接上传到MinIO + for (const part of data.parts) { + const start = part.index * chunk_size + const end = Math.min(start + chunk_size, file.size) + const chunk = file.slice(start, end) + + // 直传MinIO + const res = await fetch(part.url, { + method: 'PUT', + body: chunk, + }) + const etag = res.headers.get('ETag') || '' + + // 确认分片 + await confirmChunk(upload_id, part.index, etag) + } + } else { + // 本地中转模式: 上传到服务端 + for (let i = 0; i < total; i++) { + const start = i * chunk_size + const end = Math.min(start + chunk_size, file.size) + const chunk = file.slice(start, end) + await uploadChunk(upload_id, i, chunk) + } + } + + // 合并 + await mergeChunks(upload_id) +} + async function handleCreateFolder() { if (!newFolderName.value.trim()) { ElMessage.warning('请输入文件夹名称'); return } try { - await createFolder(newFolderName.value, fileStore.currentFolderId) + await createFolder(newFolderName.value, fileStore.currentFolderId, newFolderBizId.value || undefined) ElMessage.success('创建成功') showCreateFolderDialog.value = false newFolderName.value = '' + newFolderBizId.value = '' fileStore.refresh() } catch (e) {} } @@ -544,7 +703,23 @@ function handleCommand(cmd: string) { showPasswordDialog.value = true return } - if (cmd === 'trash' || cmd === 'shares') { ElMessage.info('功能开发中') } + if (cmd === 'trash') { window.location.href = '/trash'; return } + if (cmd === 'shares') { window.location.href = '/shares'; return } +} + +// ========== 详情 ========== +async function showInfo(row: any) { + infoData.value = { ...row } + showInfoDialog.value = true + // 如果是文件,加载权限列表 + if (!row.isFolder) { + try { + const res = await getFilePermissions(row.id) + infoData.value.permissions = res.data || [] + } catch (e) { + infoData.value.permissions = [] + } + } } async function handleChangePassword() { @@ -598,7 +773,10 @@ async function handleChangePassword() { .toolbar-left, .toolbar-right { display: flex; align-items: center; gap: 8px; } /* 文件内容区 */ -.file-content { flex: 1; overflow: auto; padding: 16px 24px; } +.file-content { flex: 1; overflow: auto; padding: 16px 24px; display: flex; flex-direction: column; } + +/* 分页栏 */ +.pagination-bar { display: flex; justify-content: flex-end; padding: 16px 0 0; border-top: 1px solid #e5e7eb; margin-top: auto; } /* 文件名单元格 */ .file-name-cell { display: flex; align-items: center; gap: 10px; padding: 4px 0; } @@ -624,5 +802,7 @@ async function handleChangePassword() { .text-secondary { color: #909399; font-size: 13px; } .form-tip { font-size: 12px; color: #999; margin-top: 4px; } .share-result { margin-top: 16px; } +.share-file-name { display: block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.info-name { max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .policy-tag { font-size: 11px; background: #e0f2fe; color: #0284c7; padding: 1px 6px; border-radius: 4px; margin-left: 6px; } diff --git a/web/src/views/SharesView.vue b/web/src/views/SharesView.vue new file mode 100644 index 0000000..7a2d459 --- /dev/null +++ b/web/src/views/SharesView.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/web/src/views/TrashView.vue b/web/src/views/TrashView.vue new file mode 100644 index 0000000..b367ac0 --- /dev/null +++ b/web/src/views/TrashView.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/web/src/views/admin/AdminLayout.vue b/web/src/views/admin/AdminLayout.vue index 05678a6..24b2477 100644 --- a/web/src/views/admin/AdminLayout.vue +++ b/web/src/views/admin/AdminLayout.vue @@ -40,6 +40,9 @@ 操作日志 + + 系统日志 + IP黑名单 @@ -83,7 +86,7 @@ import { computed } from 'vue' import { useRoute } from 'vue-router' import { - Setting, DataAnalysis, User, Lock, OfficeBuilding, Box, Connection, Back, Link, Notebook, WarningFilled, + Setting, DataAnalysis, User, Lock, OfficeBuilding, Box, Connection, Back, Link, Notebook, WarningFilled, Monitor, } from '@element-plus/icons-vue' import { useUserStore } from '../../stores/user' @@ -99,6 +102,7 @@ const titleMap: Record = { '/admin/storage-assign': '策略分配', '/admin/open-platform': '开放平台', '/admin/logs': '操作日志', + '/admin/system-log': '系统日志', '/admin/blacklist': 'IP黑名单', '/admin/backup': '备份管理', } diff --git a/web/src/views/admin/Dashboard.vue b/web/src/views/admin/Dashboard.vue index 6c7459f..e041050 100644 --- a/web/src/views/admin/Dashboard.vue +++ b/web/src/views/admin/Dashboard.vue @@ -74,12 +74,16 @@ const stats = ref({ users: 0, files: 0, folders: 0, policies: 0 }) onMounted(async () => { try { - const [users, policies] = await Promise.all([ + const [users, policies, storage] = await Promise.all([ request.get('/admin/users', { params: { page: 1, size: 1 } }) as any, request.get('/admin/storage-policies') as any, + request.get('/admin/monitor/storage') as any, ]) stats.value.users = users.total || 1 stats.value.policies = (policies.data || []).length + const global = storage.data?.global || {} + stats.value.files = global.total_files || 0 + stats.value.folders = global.total_folders || 0 } catch (e) {} }) diff --git a/web/src/views/admin/SystemLog.vue b/web/src/views/admin/SystemLog.vue new file mode 100644 index 0000000..46f8c28 --- /dev/null +++ b/web/src/views/admin/SystemLog.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/web/vite.config.ts b/web/vite.config.ts index 713ba4a..6e4ce42 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,19 +1,25 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' -export default defineConfig({ - plugins: [vue()], - server: { - port: 5173, - proxy: { - '/api': { - target: 'http://localhost:8080', - changeOrigin: true, - }, - '/storage': { - target: 'http://localhost:8080', - changeOrigin: true, +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const apiTarget = env.VITE_API_TARGET || 'http://localhost:8075' + const port = parseInt(env.VITE_PORT || '5173') + + return { + plugins: [vue()], + server: { + port, + proxy: { + '/api': { + target: apiTarget, + changeOrigin: true, + }, + '/storage': { + target: apiTarget, + changeOrigin: true, + }, }, }, - }, + } })