From dc90e889e197563506d829fed6753b8767d7d3f3 Mon Sep 17 00:00:00 2001 From: RuicyWu <1063154311@qq.com> Date: Fri, 3 Jul 2026 15:58:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/router.go | 242 +++ server/config.yaml | 30 + server/config/config.go | 115 ++ server/config/database.go | 42 + server/controller/admin_controller.go | 298 +++ server/controller/auth_controller.go | 100 + server/controller/file_controller.go | 356 ++++ server/controller/folder_controller.go | 120 ++ server/controller/permission_controller.go | 285 +++ server/controller/share_controller.go | 201 ++ server/controller/sso_controller.go | 297 +++ server/docs/api.md | 894 +++++++++ server/dto/admin_dto.go | 247 +++ server/dto/auth_dto.go | 31 + server/dto/file_dto.go | 136 ++ server/dto/permission_dto.go | 60 + server/dto/sso_dto.go | 61 + server/go.mod | 69 + server/go.sum | 198 ++ server/main.go | 244 +++ server/middleware/auth.go | 158 ++ server/middleware/cors.go | 36 + server/model/department.go | 22 + server/model/file.go | 59 + server/model/folder.go | 23 + server/model/role.go | 59 + server/model/share.go | 23 + server/model/sso.go | 97 + server/model/storage_policy.go | 64 + server/model/user.go | 28 + server/repository/department_repo.go | 72 + server/repository/file_repo.go | 288 +++ server/repository/folder_repo.go | 94 + server/repository/permission_repo.go | 82 + server/repository/role_repo.go | 86 + server/repository/share_repo.go | 63 + server/repository/sso_repo.go | 94 + server/repository/storage_policy_repo.go | 45 + server/repository/user_repo.go | 68 + server/repository/user_role_repo.go | 53 + server/service/admin_service.go | 363 ++++ server/service/auth_service.go | 121 ++ server/service/file_service.go | 479 +++++ server/service/folder_service.go | 206 ++ server/service/permission_service.go | 144 ++ server/service/share_service.go | 203 ++ server/service/sso_service.go | 372 ++++ server/sql/init.sql | 222 +++ server/storage/engine.go | 37 + server/storage/factory.go | 112 ++ server/storage/local.go | 113 ++ server/utils/file.go | 107 + server/utils/hash.go | 62 + server/utils/jwt.go | 50 + server/utils/response.go | 89 + web/index.html | 13 + web/package-lock.json | 1989 +++++++++++++++++++ web/package.json | 24 + web/src/App.vue | 24 + web/src/api/auth.ts | 110 + web/src/api/file.ts | 185 ++ web/src/api/request.ts | 83 + web/src/main.ts | 23 + web/src/router/index.ts | 66 + web/src/stores/file.ts | 93 + web/src/stores/user.ts | 126 ++ web/src/views/FileManager.vue | 628 ++++++ web/src/views/Login.vue | 139 ++ web/src/views/ShareView.vue | 251 +++ web/src/views/admin/OrganizationManage.vue | 142 ++ web/src/views/admin/RoleManage.vue | 239 +++ web/src/views/admin/StoragePolicyManage.vue | 229 +++ web/src/views/admin/UserManage.vue | 268 +++ web/src/vite-env.d.ts | 7 + web/tsconfig.json | 24 + web/tsconfig.node.json | 10 + web/vite.config.ts | 19 + 77 files changed, 12912 insertions(+) create mode 100644 server/api/router.go create mode 100644 server/config.yaml create mode 100644 server/config/config.go create mode 100644 server/config/database.go create mode 100644 server/controller/admin_controller.go create mode 100644 server/controller/auth_controller.go create mode 100644 server/controller/file_controller.go create mode 100644 server/controller/folder_controller.go create mode 100644 server/controller/permission_controller.go create mode 100644 server/controller/share_controller.go create mode 100644 server/controller/sso_controller.go create mode 100644 server/docs/api.md create mode 100644 server/dto/admin_dto.go create mode 100644 server/dto/auth_dto.go create mode 100644 server/dto/file_dto.go create mode 100644 server/dto/permission_dto.go create mode 100644 server/dto/sso_dto.go create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/main.go create mode 100644 server/middleware/auth.go create mode 100644 server/middleware/cors.go create mode 100644 server/model/department.go create mode 100644 server/model/file.go create mode 100644 server/model/folder.go create mode 100644 server/model/role.go create mode 100644 server/model/share.go create mode 100644 server/model/sso.go create mode 100644 server/model/storage_policy.go create mode 100644 server/model/user.go create mode 100644 server/repository/department_repo.go create mode 100644 server/repository/file_repo.go create mode 100644 server/repository/folder_repo.go create mode 100644 server/repository/permission_repo.go create mode 100644 server/repository/role_repo.go create mode 100644 server/repository/share_repo.go create mode 100644 server/repository/sso_repo.go create mode 100644 server/repository/storage_policy_repo.go create mode 100644 server/repository/user_repo.go create mode 100644 server/repository/user_role_repo.go create mode 100644 server/service/admin_service.go create mode 100644 server/service/auth_service.go create mode 100644 server/service/file_service.go create mode 100644 server/service/folder_service.go create mode 100644 server/service/permission_service.go create mode 100644 server/service/share_service.go create mode 100644 server/service/sso_service.go create mode 100644 server/sql/init.sql create mode 100644 server/storage/engine.go create mode 100644 server/storage/factory.go create mode 100644 server/storage/local.go create mode 100644 server/utils/file.go create mode 100644 server/utils/hash.go create mode 100644 server/utils/jwt.go create mode 100644 server/utils/response.go create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.vue create mode 100644 web/src/api/auth.ts create mode 100644 web/src/api/file.ts create mode 100644 web/src/api/request.ts create mode 100644 web/src/main.ts create mode 100644 web/src/router/index.ts create mode 100644 web/src/stores/file.ts create mode 100644 web/src/stores/user.ts create mode 100644 web/src/views/FileManager.vue create mode 100644 web/src/views/Login.vue create mode 100644 web/src/views/ShareView.vue create mode 100644 web/src/views/admin/OrganizationManage.vue create mode 100644 web/src/views/admin/RoleManage.vue create mode 100644 web/src/views/admin/StoragePolicyManage.vue create mode 100644 web/src/views/admin/UserManage.vue create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/server/api/router.go b/server/api/router.go new file mode 100644 index 0000000..f2c9b23 --- /dev/null +++ b/server/api/router.go @@ -0,0 +1,242 @@ +// Package api 定义API路由 +package api + +import ( + "seeyon-filesystem/config" + "seeyon-filesystem/controller" + "seeyon-filesystem/middleware" + + "github.com/gin-gonic/gin" +) + +// SetupRouter 初始化并配置所有路由 +func SetupRouter() *gin.Engine { + cfg := config.AppConfig + + r := gin.Default() + + // 全局中间件 + r.Use(middleware.CORS()) + r.Use(middleware.UTF8ContentType()) + r.Use(middleware.TrafficMonitor()) // 流量监控 + r.Use(middleware.IPBlacklistMiddleware()) // IP黑名单 + r.Use(middleware.OperationLogger()) // 操作日志 + + // 静态文件服务(本地存储的文件) + r.Static("/storage", cfg.Storage.LocalBasePath) + + // 健康检查 + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // 初始化控制器 + authCtrl := controller.NewAuthController() + fileCtrl := controller.NewFileController() + accessCtrl := controller.NewFileAccessController() + folderCtrl := controller.NewFolderController() + shareCtrl := controller.NewShareController() + ssoCtrl := controller.NewSSOController() + permCtrl := controller.NewPermissionController() + logCtrl := controller.NewLogController() + backupCtrl := controller.NewBackupController() + adminCtrl := controller.NewAdminController() + storageCtrl := controller.NewStorageController() + assignCtrl := controller.NewStorageAssignController() + chunkCtrl := controller.NewChunkUploadController() + monitorCtrl := controller.NewMonitorController() + openCtrl := controller.NewOpenAPIController() + + // API路由组 + api := r.Group("/api") + { + // ============ 认证 (无需登录) ============ + auth := api.Group("/auth") + { + auth.POST("/login", authCtrl.Login) + auth.POST("/register", authCtrl.Register) + } + + // ============ SSO (无需登录) ============ + sso := api.Group("/sso") + { + sso.GET("/providers", ssoCtrl.GetProviders) // 获取SSO提供商列表 + sso.GET("/auth/url", ssoCtrl.GetAuthURL) // 获取授权跳转URL + sso.GET("/callback", ssoCtrl.Callback) // SSO回调(GET, 重定向) + sso.POST("/callback", ssoCtrl.CallbackJSON) + } + + // ============ 开放API (无需登录, OA调用) ============ + openapi := api.Group("/openapi") + { + openapi.POST("/token/validate", openCtrl.ValidateToken) + openapi.POST("/token/generate", openCtrl.GenerateTestToken) + } + + // ============ 需要登录的路由 ============ + protected := api.Group("") + protected.Use(middleware.JWTAuth(cfg.JWT.Secret)) + { + // 用户信息 + protected.GET("/auth/profile", authCtrl.GetProfile) + protected.GET("/auth/permissions", permCtrl.GetUserPermissions) + protected.PUT("/auth/password", authCtrl.ChangePassword) + protected.GET("/user/storage-info", assignCtrl.GetMyStorageInfo) + + // 文件管理 + file := protected.Group("/file") + { + file.POST("/upload", middleware.RequirePermission("file:upload"), fileCtrl.Upload) + + // 分片上传 + 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/merge/:uploadId", middleware.RequirePermission("file:upload"), chunkCtrl.Merge) + file.GET("/upload/progress/:uploadId", chunkCtrl.Progress) + file.DELETE("/upload/:uploadId", chunkCtrl.Cancel) + file.GET("/info", fileCtrl.GetByKey) // 根据fileKey查询 + file.GET("/search", fileCtrl.Search) + file.GET("/trash", fileCtrl.ListTrashed) + file.GET("/:id", fileCtrl.GetByID) + file.GET("/:id/download", fileCtrl.Download) // 直接下载文件流 + file.GET("/:id/download-url", fileCtrl.GetDownloadURL) // 获取下载链接 + file.GET("/:id/preview", fileCtrl.Preview) // 预览文件流 + file.GET("/:id/preview-url", fileCtrl.GetPreviewURL) // 获取预览链接 + file.GET("/:id/access", accessCtrl.Access) // 统一访问(自动选择模式) + file.GET("/:id/access-url", accessCtrl.GetAccessURL) // 获取访问URL + file.PUT("/:id/rename", middleware.RequirePermission("file:update"), fileCtrl.Rename) + file.POST("/:id/move", middleware.RequirePermission("file:update"), fileCtrl.Move) + 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) + } + + // 文件夹管理 + folder := protected.Group("/folder") + { + folder.POST("", middleware.RequirePermission("file:create"), folderCtrl.Create) + folder.GET("/:id/children", folderCtrl.ListChildren) + folder.GET("/root", func(c *gin.Context) { + c.Params = append(c.Params, gin.Param{Key: "id", Value: "root"}) + folderCtrl.ListChildren(c) + }) + folder.PUT("/:id/rename", middleware.RequirePermission("file:update"), folderCtrl.Rename) + folder.DELETE("/:id", middleware.RequirePermission("file:delete"), folderCtrl.Delete) + } + + // 分享管理 + share := protected.Group("/share") + { + share.POST("", middleware.RequirePermission("share:create"), shareCtrl.Create) + share.GET("", shareCtrl.ListMyShares) + share.DELETE("/:id", shareCtrl.Cancel) + } + } + + // ============ 管理员接口 ============ + admin := api.Group("/admin") + admin.Use(middleware.JWTAuth(cfg.JWT.Secret)) + admin.Use(middleware.AdminAuth()) + { + // 角色管理 + admin.GET("/roles", permCtrl.ListRoles) + admin.POST("/roles", permCtrl.CreateRole) + admin.PUT("/roles/:id", permCtrl.UpdateRole) + admin.DELETE("/roles/:id", permCtrl.DeleteRole) + admin.PUT("/roles/:id/permissions", permCtrl.SetRolePermissions) + + // 权限管理 + admin.GET("/permissions", permCtrl.ListPermissions) + admin.POST("/permissions", permCtrl.CreatePermission) + admin.DELETE("/permissions/:id", permCtrl.DeletePermission) + + // 用户管理 + admin.GET("/users", adminCtrl.ListUsers) + admin.POST("/users", adminCtrl.CreateUser) + admin.PUT("/users/:id", adminCtrl.UpdateUser) + admin.DELETE("/users/:id", adminCtrl.DeleteUser) + admin.PUT("/users/:id/password", adminCtrl.ResetPassword) + admin.PUT("/users/:id/roles", permCtrl.SetUserRoles) + + // 存储策略管理 + admin.GET("/storage-policies", adminCtrl.ListStoragePolicies) + admin.POST("/storage-policies", adminCtrl.CreateStoragePolicy) + admin.PUT("/storage-policies/:id", adminCtrl.UpdateStoragePolicy) + admin.DELETE("/storage-policies/:id", adminCtrl.DeleteStoragePolicy) + admin.POST("/storage-policies/:id/test", adminCtrl.TestStoragePolicy) // 测试已保存策略 + admin.POST("/storage-policies/test", adminCtrl.TestNewStoragePolicy) // 测试新配置 + + // 组织架构管理 + admin.GET("/departments", adminCtrl.ListDepartments) + admin.POST("/departments", adminCtrl.CreateDepartment) + admin.PUT("/departments/:id", adminCtrl.UpdateDepartment) + admin.DELETE("/departments/:id", adminCtrl.DeleteDepartment) + + // SSO提供商管理 + admin.GET("/sso/providers", ssoCtrl.AdminListProviders) + admin.GET("/sso/providers/:id", ssoCtrl.AdminGetProvider) + admin.POST("/sso/providers", ssoCtrl.AdminCreateProvider) + admin.PUT("/sso/providers/:id", ssoCtrl.AdminUpdateProvider) + admin.PUT("/sso/providers/:id/toggle", ssoCtrl.AdminToggleProvider) + admin.DELETE("/sso/providers/:id", ssoCtrl.AdminDeleteProvider) + + // CDN/存储设置 + admin.GET("/settings/cdn", storageCtrl.GetCDNConfig) + admin.PUT("/settings/cdn", storageCtrl.UpdateCDNConfig) + + // 存储策略分配 + admin.GET("/storage-assignments", assignCtrl.ListAssignments) + admin.POST("/storage-assignments/default", assignCtrl.SetDefault) + admin.POST("/storage-assignments/user", assignCtrl.AssignToUser) + admin.POST("/storage-assignments/role", assignCtrl.AssignToRole) + admin.PUT("/storage-assignments/:id", assignCtrl.UpdateAssignment) + admin.DELETE("/storage-assignments/:id", assignCtrl.DeleteAssignment) + + // 流量监控 + admin.GET("/monitor/traffic", monitorCtrl.GetTrafficStats) + admin.GET("/monitor/requests", monitorCtrl.GetRecentRequests) + admin.GET("/monitor/storage", monitorCtrl.GetStorageStats) + + // IP黑名单 + admin.GET("/blacklist", monitorCtrl.GetBlacklist) + admin.PUT("/blacklist", monitorCtrl.SetBlacklist) + admin.POST("/blacklist", monitorCtrl.AddBlockedIP) + admin.DELETE("/blacklist/:ip", monitorCtrl.RemoveBlockedIP) + + // 开放平台应用管理 + admin.GET("/apps", openCtrl.ListApps) + admin.POST("/apps", openCtrl.CreateApp) + admin.DELETE("/apps/:id", openCtrl.DeleteApp) + admin.PUT("/apps/:id/toggle", openCtrl.ToggleApp) + admin.PUT("/apps/:id/users", openCtrl.BindUsers) + admin.DELETE("/apps/:id/users/:userId", openCtrl.UnbindUser) + + // 操作日志 + admin.GET("/logs", logCtrl.List) + admin.GET("/logs/stats", logCtrl.Stats) + admin.POST("/logs/clean", logCtrl.Clean) + + // 备份管理 + admin.GET("/backup/policies", backupCtrl.ListPolicies) + admin.POST("/backup/policies", backupCtrl.CreatePolicy) + admin.DELETE("/backup/policies/:id", backupCtrl.DeletePolicy) + admin.PUT("/backup/policies/:id/toggle", backupCtrl.TogglePolicy) + admin.POST("/backup/execute/:id", backupCtrl.ExecuteBackup) + admin.GET("/backup/logs", backupCtrl.ListLogs) + admin.GET("/backup/backups", backupCtrl.ListBackups) + admin.POST("/backup/restore/:logId", backupCtrl.Restore) + } + + // ============ 分享公开访问 (无需登录) ============ + s := api.Group("/s") + { + s.GET("/:code", shareCtrl.GetShareInfo) + s.POST("/:code/verify", shareCtrl.VerifySharePassword) + s.GET("/:code/preview", shareCtrl.PreviewShareFile) + s.GET("/:code/download", shareCtrl.DownloadShareFile) + s.GET("/:code/access", accessCtrl.AccessByShare) + } + } + + return r +} diff --git a/server/config.yaml b/server/config.yaml new file mode 100644 index 0000000..81e8dcd --- /dev/null +++ b/server/config.yaml @@ -0,0 +1,30 @@ +# ============================================= +# 文件管理系统配置 +# ============================================= + +server: + port: 8080 + mode: debug + public_url: "http://localhost:5173" + +database: + host: 127.0.0.1 + port: 3306 + username: root + password: "123456" + dbname: seeyon_fs + +jwt: + secret: seeyon-filesystem-jwt-secret-key-2024 + expiration: 24 + +storage: + default_policy_id: 1 + local_base_path: D:/Seeyon/A8/localfile + cdn_host: "http://localhost: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" diff --git a/server/config/config.go b/server/config/config.go new file mode 100644 index 0000000..6cbd85e --- /dev/null +++ b/server/config/config.go @@ -0,0 +1,115 @@ +// Package config 负责加载和管理应用配置 +// 使用 viper 从 YAML 配置文件中读取配置项 +package config + +import ( + "fmt" + "log" + + "github.com/spf13/viper" +) + +// AppConfig 全局配置实例 +var AppConfig *Config + +// Config 应用配置结构体 +type Config struct { + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + JWT JWTConfig `mapstructure:"jwt"` + Storage StorageConfig `mapstructure:"storage"` + Backup BackupConfig `mapstructure:"backup"` +} + +// BackupConfig 备份工具配置 +type BackupConfig struct { + MysqlDumpPath string `mapstructure:"mysqldump_path"` // mysqldump 路径 + MysqlPath string `mapstructure:"mysql_path"` // mysql 路径 +} + +// ServerConfig 服务器配置 +type ServerConfig struct { + Port int `mapstructure:"port"` + Mode string `mapstructure:"mode"` // debug / release + PublicURL string `mapstructure:"public_url"` // 公网访问地址, 如 http://files.example.com +} + +// DatabaseConfig 数据库配置 +type DatabaseConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + DBName string `mapstructure:"dbname"` +} + +// 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) +} + +// JWTConfig JWT配置 +type JWTConfig struct { + Secret string `mapstructure:"secret"` + Expiration int `mapstructure:"expiration"` // 过期时间(小时) +} + +// StorageConfig 存储配置 +type StorageConfig struct { + DefaultPolicyID uint `mapstructure:"default_policy_id"` + LocalBasePath string `mapstructure:"local_base_path"` + CDNHost string `mapstructure:"cdn_host"` // CDN地址, 如 http://localhost:3090 + CDNPathPrefix string `mapstructure:"cdn_path_prefix"` // CDN路径前缀, 如 /public +} + +// Load 加载配置文件 +// 配置文件搜索路径: ./config.yaml, ./config/config.yaml +func Load() (*Config, error) { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + viper.AddConfigPath("./config") + viper.AddConfigPath("./server") + + // 设置默认值 + setDefaults() + + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("读取配置文件失败: %w", err) + } + log.Println("未找到配置文件, 使用默认配置") + } + + AppConfig = &Config{} + if err := viper.Unmarshal(AppConfig); err != nil { + return nil, fmt.Errorf("解析配置失败: %w", err) + } + + return AppConfig, nil +} + +// setDefaults 设置配置默认值 +func setDefaults() { + // 服务器默认配置 + viper.SetDefault("server.port", 8080) + viper.SetDefault("server.mode", "debug") + + // 数据库默认配置 + viper.SetDefault("database.host", "127.0.0.1") + viper.SetDefault("database.port", 3306) + viper.SetDefault("database.username", "root") + viper.SetDefault("database.password", "root") + viper.SetDefault("database.dbname", "seeyon_fs") + + // JWT默认配置 + viper.SetDefault("jwt.secret", "seeyon-filesystem-jwt-secret-key-2024") + viper.SetDefault("jwt.expiration", 24) + + // 存储默认配置 + viper.SetDefault("storage.default_policy_id", 1) + viper.SetDefault("storage.local_base_path", "./data/storage") + viper.SetDefault("storage.cdn_host", "") + viper.SetDefault("storage.cdn_path_prefix", "/public") +} diff --git a/server/config/database.go b/server/config/database.go new file mode 100644 index 0000000..083aa9a --- /dev/null +++ b/server/config/database.go @@ -0,0 +1,42 @@ +// Package config 数据库连接管理 +package config + +import ( + "fmt" + "log" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// DB 全局数据库实例 +var DB *gorm.DB + +// InitDB 初始化数据库连接 +func InitDB(cfg *DatabaseConfig) error { + dsn := cfg.DSN() + + var err error + DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + }) + if err != nil { + return fmt.Errorf("连接数据库失败: %w", err) + } + + // 获取底层 sql.DB 进行连接池配置 + sqlDB, err := DB.DB() + if err != nil { + return fmt.Errorf("获取数据库实例失败: %w", err) + } + + // 连接池配置 + sqlDB.SetMaxIdleConns(10) // 最大空闲连接数 + sqlDB.SetMaxOpenConns(100) // 最大打开连接数 + sqlDB.SetConnMaxLifetime(time.Hour) // 连接最大存活时间 + + log.Println("数据库连接成功") + return nil +} diff --git a/server/controller/admin_controller.go b/server/controller/admin_controller.go new file mode 100644 index 0000000..cf4136f --- /dev/null +++ b/server/controller/admin_controller.go @@ -0,0 +1,298 @@ +package controller + +import ( + "strconv" + + "seeyon-filesystem/dto" + "seeyon-filesystem/model" + "seeyon-filesystem/service" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// AdminController 管理后台控制器 +type AdminController struct { + adminService *service.AdminService + storageService *service.StorageTestService +} + +func NewAdminController() *AdminController { + return &AdminController{ + adminService: service.NewAdminService(), + storageService: service.NewStorageTestService(), + } +} + +// ========== 用户管理 ========== + +// ListUsers 用户列表 +// GET /api/admin/users?page=1&size=20 +func (c *AdminController) ListUsers(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(ctx.DefaultQuery("size", "20")) + if page < 1 { page = 1 } + if size < 1 || size > 100 { size = 20 } + + users, total, err := c.adminService.ListUsers(page, size) + if err != nil { + utils.ServerError(ctx, "查询用户失败") + return + } + utils.PageSuccess(ctx, users, total, page, size) +} + +// CreateUser 创建用户 +// POST /api/admin/users +func (c *AdminController) CreateUser(ctx *gin.Context) { + var req dto.UserCreateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.adminService.CreateUser(&req); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "用户创建成功", nil) +} + +// UpdateUser 更新用户 +// PUT /api/admin/users/:id +func (c *AdminController) UpdateUser(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的用户ID") + return + } + + var req dto.UserUpdateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.adminService.UpdateUser(id, &req); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "更新成功", nil) +} + +// DeleteUser 删除用户 +// DELETE /api/admin/users/:id +func (c *AdminController) DeleteUser(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的用户ID") + return + } + + if err := c.adminService.DeleteUser(id); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "删除成功", nil) +} + +// ResetPassword 重置密码 +// PUT /api/admin/users/:id/password +func (c *AdminController) ResetPassword(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的用户ID") + return + } + + var req struct { + Password string `json:"password" binding:"required,min=6"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.adminService.ResetPassword(id, req.Password); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "密码重置成功", nil) +} + +// ========== 存储策略管理 ========== + +// ListStoragePolicies 存储策略列表 +// GET /api/admin/storage-policies +func (c *AdminController) ListStoragePolicies(ctx *gin.Context) { + policies, err := c.adminService.ListStoragePolicies() + if err != nil { + utils.ServerError(ctx, "查询失败") + return + } + utils.Success(ctx, policies) +} + +// CreateStoragePolicy 创建存储策略 +// POST /api/admin/storage-policies +func (c *AdminController) CreateStoragePolicy(ctx *gin.Context) { + var req dto.StoragePolicyCreateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.adminService.CreateStoragePolicy(&req); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "创建成功", nil) +} + +// UpdateStoragePolicy 更新存储策略 +// PUT /api/admin/storage-policies/:id +func (c *AdminController) UpdateStoragePolicy(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的策略ID") + return + } + + var req dto.StoragePolicyUpdateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.adminService.UpdateStoragePolicy(id, &req); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "更新成功", nil) +} + +// DeleteStoragePolicy 删除存储策略 +// DELETE /api/admin/storage-policies/:id +func (c *AdminController) DeleteStoragePolicy(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的策略ID") + return + } + + if err := c.adminService.DeleteStoragePolicy(id); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "删除成功", nil) +} + +// TestStoragePolicy 测试存储策略连通性 +// POST /api/admin/storage-policies/:id/test +func (c *AdminController) TestStoragePolicy(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的策略ID") + return + } + + result, err := c.storageService.TestPolicy(id) + if err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + utils.Success(ctx, result) +} + +// TestNewStoragePolicy 测试新配置(未保存) +// POST /api/admin/storage-policies/test +func (c *AdminController) TestNewStoragePolicy(ctx *gin.Context) { + var req struct { + Type string `json:"type" binding:"required"` + Config dto.PolicyConfigDTO `json:"config" binding:"required"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + cfg := model.PolicyConfig{ + BasePath: req.Config.BasePath, + Endpoint: req.Config.Endpoint, + Bucket: req.Config.Bucket, + AccessKey: req.Config.AccessKey, + SecretKey: req.Config.SecretKey, + Region: req.Config.Region, + UseSSL: req.Config.UseSSL, + } + + result := c.storageService.TestNewPolicy(req.Type, cfg) + utils.Success(ctx, result) +} + +// ========== 组织架构管理 ========== + +// ListDepartments 部门列表(树形) +// GET /api/admin/departments +func (c *AdminController) ListDepartments(ctx *gin.Context) { + depts, err := c.adminService.ListDepartments() + if err != nil { + utils.ServerError(ctx, "查询失败") + return + } + utils.Success(ctx, depts) +} + +// CreateDepartment 创建部门 +// POST /api/admin/departments +func (c *AdminController) CreateDepartment(ctx *gin.Context) { + var req dto.DeptCreateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.adminService.CreateDepartment(&req); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "创建成功", nil) +} + +// UpdateDepartment 更新部门 +// PUT /api/admin/departments/:id +func (c *AdminController) UpdateDepartment(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的部门ID") + return + } + + var req dto.DeptUpdateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.adminService.UpdateDepartment(id, &req); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "更新成功", nil) +} + +// DeleteDepartment 删除部门 +// DELETE /api/admin/departments/:id +func (c *AdminController) DeleteDepartment(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的部门ID") + return + } + + if err := c.adminService.DeleteDepartment(id); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "删除成功", nil) +} diff --git a/server/controller/auth_controller.go b/server/controller/auth_controller.go new file mode 100644 index 0000000..804acd1 --- /dev/null +++ b/server/controller/auth_controller.go @@ -0,0 +1,100 @@ +// Package controller 提供HTTP请求处理层 +// 负责参数绑定、校验, 调用Service层, 返回响应 +package controller + +import ( + "seeyon-filesystem/dto" + "seeyon-filesystem/middleware" + "seeyon-filesystem/service" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// AuthController 认证控制器 +type AuthController struct { + authService *service.AuthService +} + +// NewAuthController 创建认证控制器实例 +func NewAuthController() *AuthController { + return &AuthController{ + authService: service.NewAuthService(), + } +} + +// Login 用户登录 +// POST /api/auth/login +func (c *AuthController) Login(ctx *gin.Context) { + var req dto.LoginRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + resp, err := c.authService.Login(&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) { + var req dto.RegisterRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.authService.Register(&req); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "注册成功", nil) +} + +// GetProfile 获取当前用户信息 +// GET /api/auth/profile +func (c *AuthController) GetProfile(ctx *gin.Context) { + userID := middleware.GetCurrentUserID(ctx) + if userID == 0 { + utils.Unauthorized(ctx, "未登录") + return + } + + role, _ := ctx.Get("role") + + utils.Success(ctx, gin.H{ + "user_id": userID, + "username": middleware.GetCurrentUsername(ctx), + "role": role, + }) +} + +// ChangePassword 修改密码 +// PUT /api/auth/password +func (c *AuthController) ChangePassword(ctx *gin.Context) { + userID := middleware.GetCurrentUserID(ctx) + if userID == 0 { + utils.Unauthorized(ctx, "未登录") + return + } + + var req dto.ChangePasswordRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.authService.ChangePassword(userID, &req); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "密码修改成功", nil) +} diff --git a/server/controller/file_controller.go b/server/controller/file_controller.go new file mode 100644 index 0000000..717cca2 --- /dev/null +++ b/server/controller/file_controller.go @@ -0,0 +1,356 @@ +package controller + +import ( + "net/http" + "strconv" + "strings" + + "seeyon-filesystem/dto" + "seeyon-filesystem/middleware" + "seeyon-filesystem/service" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// FileController 文件控制器 +type FileController struct { + fileService *service.FileService +} + +// NewFileController 创建文件控制器实例 +func NewFileController() *FileController { + return &FileController{ + fileService: service.NewFileService(), + } +} + +// Upload 上传文件 +// POST /api/file/upload?folder_id=1&storage_policy_id=1 +func (c *FileController) Upload(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + // 获取文件 + fileHeader, err := ctx.FormFile("file") + if err != nil { + utils.BadRequest(ctx, "请选择要上传的文件") + return + } + + // 获取目标文件夹ID + folderIDStr := ctx.DefaultQuery("folder_id", "0") + folderID, _ := strconv.ParseUint(folderIDStr, 10, 64) + + // 获取存储策略ID(可选, 0=使用默认策略) + policyIDStr := ctx.DefaultQuery("storage_policy_id", "0") + storagePolicyID, _ := strconv.ParseUint(policyIDStr, 10, 64) + + // 调用服务层 + result, err := c.fileService.Upload(fileHeader, folderID, ownerID, storagePolicyID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// GetByID 获取文件信息 +// GET /api/file/:id +func (c *FileController) GetByID(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + result, err := c.fileService.GetByID(id, ownerID) + if err != nil { + utils.NotFound(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// Download 下载文件 +// GET /api/file/:id/download +func (c *FileController) Download(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + reader, file, err := c.fileService.Download(id, ownerID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + defer reader.Close() + + // 设置下载响应头 + ctx.Header("Content-Disposition", "attachment; filename=\""+file.Name+"\"") + ctx.Header("Content-Type", file.MimeType) + ctx.DataFromReader(http.StatusOK, file.Size, file.MimeType, reader, nil) +} + +// GetDownloadURL 获取文件下载链接 +// GET /api/file/:id/download-url +func (c *FileController) GetDownloadURL(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + downloadURL, err := c.fileService.GetDownloadURL(id) + if err != nil { + utils.NotFound(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{"download_url": downloadURL}) +} + +// GetPreviewURL 获取文件预览链接 +// GET /api/file/:id/preview-url +func (c *FileController) GetPreviewURL(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + previewURL, err := c.fileService.GetPreviewURL(id) + if err != nil { + utils.NotFound(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{"preview_url": previewURL}) +} + +// Preview 文件预览(直接返回文件流, 浏览器内联展示) +// GET /api/file/:id/preview +func (c *FileController) Preview(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + reader, file, err := c.fileService.Download(id, ownerID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + defer reader.Close() + + // 内联展示而非下载 + ctx.Header("Content-Disposition", "inline; filename=\""+file.Name+"\"") + ctx.Header("Content-Type", file.MimeType) + ctx.DataFromReader(http.StatusOK, file.Size, file.MimeType, reader, nil) +} + +// GetByKey 根据fileKey查询文件信息 +// GET /api/file/info?key=xxx +func (c *FileController) GetByKey(ctx *gin.Context) { + key := ctx.Query("key") + if key == "" { + utils.BadRequest(ctx, "请提供fileKey参数") + return + } + + result, err := c.fileService.GetByKey(key) + if err != nil { + utils.NotFound(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// Rename 重命名文件 +// PUT /api/file/:id/rename +func (c *FileController) Rename(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 struct { + NewName string `json:"new_name" binding:"required"` + Version int `json:"version"` // 乐观锁版本号 + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.fileService.Rename(id, req.NewName, ownerID, req.Version); err != nil { + if strings.Contains(err.Error(), "已被其他人修改") { + utils.Error(ctx, 409, 409, err.Error()) + return + } + utils.ServerError(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "重命名成功", nil) +} + +// Move 移动文件 +// POST /api/file/:id/move +func (c *FileController) Move(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 struct { + TargetFolderID uint64 `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 { + utils.ServerError(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "移动成功", nil) +} + +// SoftDelete 移入回收站 +// DELETE /api/file/:id +func (c *FileController) SoftDelete(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + if err := c.fileService.SoftDelete(id, ownerID); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "已移入回收站", nil) +} + +// Restore 从回收站恢复 +// POST /api/file/:id/restore +func (c *FileController) Restore(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + if err := c.fileService.Restore(id, ownerID); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "已恢复", nil) +} + +// HardDelete 永久删除 +// DELETE /api/file/:id/permanent +func (c *FileController) HardDelete(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + if err := c.fileService.HardDelete(id, ownerID); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "已永久删除", nil) +} + +// Search 搜索文件 +// GET /api/file/search?keyword=xxx +func (c *FileController) Search(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + keyword := ctx.Query("keyword") + typeFilter := ctx.Query("type") // 可选: image/video/document/audio/archive/code + + if keyword == "" && typeFilter == "" { + utils.BadRequest(ctx, "请输入搜索关键词或指定类型") + return + } + + var results []dto.FileVO + var err error + + if typeFilter != "" { + // 按类型搜索 + exts := getExtensionsByType(typeFilter) + results, err = c.fileService.SearchByType(ownerID, exts) + } else { + // 按关键词搜索(支持|分隔多关键词) + results, err = c.fileService.Search(ownerID, keyword) + } + + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, results) +} + +// getExtensionsByType 根据类型获取扩展名列表 +func getExtensionsByType(fileType string) []string { + typeMap := map[string][]string{ + "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"}, + } + if exts, ok := typeMap[fileType]; ok { + return exts + } + return []string{fileType} +} + +// ListTrashed 查询回收站 +// GET /api/file/trash +func (c *FileController) ListTrashed(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + results, err := c.fileService.ListTrashed(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 new file mode 100644 index 0000000..4d3952a --- /dev/null +++ b/server/controller/folder_controller.go @@ -0,0 +1,120 @@ +package controller + +import ( + "strconv" + "strings" + + "seeyon-filesystem/dto" + "seeyon-filesystem/middleware" + "seeyon-filesystem/service" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// FolderController 文件夹控制器 +type FolderController struct { + folderService *service.FolderService +} + +// NewFolderController 创建文件夹控制器实例 +func NewFolderController() *FolderController { + return &FolderController{ + folderService: service.NewFolderService(), + } +} + +// Create 创建文件夹 +// POST /api/folder +func (c *FolderController) Create(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + var req dto.CreateFolderRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + result, err := c.folderService.Create(&req, ownerID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// ListChildren 获取文件夹子项 +// GET /api/folder/:id/children 或 GET /api/folder/root +func (c *FolderController) ListChildren(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + idStr := ctx.Param("id") + var folderID uint64 + if idStr == "root" { + folderID = 0 + } else { + var err error + folderID, err = strconv.ParseUint(idStr, 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件夹ID") + return + } + } + + result, err := c.folderService.ListChildren(folderID, ownerID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// Rename 重命名文件夹 +// PUT /api/folder/:id/rename +func (c *FolderController) Rename(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.RenameRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.folderService.Rename(id, req.NewName, ownerID, req.Version); err != nil { + if strings.Contains(err.Error(), "已被其他人修改") { + utils.Error(ctx, 409, 409, err.Error()) + return + } + utils.ServerError(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "重命名成功", nil) +} + +// Delete 删除文件夹 +// DELETE /api/folder/:id +func (c *FolderController) Delete(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件夹ID") + return + } + + if err := c.folderService.Delete(id, ownerID); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "已移入回收站", nil) +} diff --git a/server/controller/permission_controller.go b/server/controller/permission_controller.go new file mode 100644 index 0000000..fe510a1 --- /dev/null +++ b/server/controller/permission_controller.go @@ -0,0 +1,285 @@ +package controller + +import ( + "strconv" + + "seeyon-filesystem/dto" + "seeyon-filesystem/middleware" + "seeyon-filesystem/model" + "seeyon-filesystem/service" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// PermissionController 权限管理控制器 +type PermissionController struct { + permService *service.PermissionService +} + +func NewPermissionController() *PermissionController { + return &PermissionController{ + permService: service.NewPermissionService(), + } +} + +// ========== 角色管理 ========== + +// ListRoles 获取所有角色 +// GET /api/admin/roles +func (c *PermissionController) ListRoles(ctx *gin.Context) { + roles, err := c.permService.ListRoles() + if err != nil { + utils.ServerError(ctx, "获取角色列表失败") + return + } + + vos := make([]dto.RoleVO, 0, len(roles)) + for _, r := range roles { + perms := make([]dto.PermissionVO, 0, len(r.Permissions)) + for _, p := range r.Permissions { + perms = append(perms, dto.PermissionVO{ + ID: p.ID, + Code: p.Code, + Name: p.Name, + Resource: p.Resource, + Action: p.Action, + Description: p.Description, + }) + } + vos = append(vos, dto.RoleVO{ + ID: r.ID, + Name: r.Name, + DisplayName: r.DisplayName, + Description: r.Description, + IsSystem: r.IsSystem, + Status: r.Status, + Permissions: perms, + }) + } + + utils.Success(ctx, vos) +} + +// CreateRole 创建角色 +// POST /api/admin/roles +func (c *PermissionController) CreateRole(ctx *gin.Context) { + var req dto.RoleCreateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + role := &model.Role{ + Name: req.Name, + DisplayName: req.DisplayName, + Description: req.Description, + } + + if err := c.permService.CreateRole(role); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "角色创建成功", nil) +} + +// UpdateRole 更新角色 +// PUT /api/admin/roles/:id +func (c *PermissionController) UpdateRole(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的角色ID") + return + } + + var req dto.RoleUpdateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + role := &model.Role{ + ID: id, + DisplayName: req.DisplayName, + Description: req.Description, + } + + if err := c.permService.UpdateRole(role); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "更新成功", nil) +} + +// DeleteRole 删除角色 +// DELETE /api/admin/roles/:id +func (c *PermissionController) DeleteRole(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的角色ID") + return + } + + if err := c.permService.DeleteRole(id); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "删除成功", nil) +} + +// SetRolePermissions 设置角色权限 +// PUT /api/admin/roles/:id/permissions +func (c *PermissionController) SetRolePermissions(ctx *gin.Context) { + roleID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的角色ID") + return + } + + var req dto.SetRolePermissionsRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.permService.SetRolePermissions(roleID, req.PermissionIDs); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "权限设置成功", nil) +} + +// ========== 权限管理 ========== + +// ListPermissions 获取所有权限 +// GET /api/admin/permissions +func (c *PermissionController) ListPermissions(ctx *gin.Context) { + perms, err := c.permService.ListPermissions() + if err != nil { + utils.ServerError(ctx, "获取权限列表失败") + return + } + + vos := make([]dto.PermissionVO, 0, len(perms)) + for _, p := range perms { + vos = append(vos, dto.PermissionVO{ + ID: p.ID, + Code: p.Code, + Name: p.Name, + Resource: p.Resource, + Action: p.Action, + Description: p.Description, + }) + } + + utils.Success(ctx, vos) +} + +// CreatePermission 创建权限 +// POST /api/admin/permissions +func (c *PermissionController) CreatePermission(ctx *gin.Context) { + var req dto.PermissionCreateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + perm := &model.Permission{ + Code: req.Code, + Name: req.Name, + Resource: req.Resource, + Action: req.Action, + Description: req.Description, + } + + if err := c.permService.CreatePermission(perm); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "权限创建成功", nil) +} + +// DeletePermission 删除权限 +// DELETE /api/admin/permissions/:id +func (c *PermissionController) DeletePermission(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的权限ID") + return + } + + if err := c.permService.DeletePermission(id); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "删除成功", nil) +} + +// ========== 用户角色管理 ========== + +// SetUserRoles 设置用户角色 +// PUT /api/admin/users/:id/roles +func (c *PermissionController) SetUserRoles(ctx *gin.Context) { + userID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的用户ID") + return + } + + var req dto.SetUserRolesRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.permService.SetUserRoles(userID, req.RoleIDs); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "角色设置成功", nil) +} + +// GetUserPermissions 获取当前用户的权限信息 +// GET /api/auth/permissions +func (c *PermissionController) GetUserPermissions(ctx *gin.Context) { + userID := middleware.GetCurrentUserID(ctx) + if userID == 0 { + utils.Unauthorized(ctx, "未登录") + return + } + + // 获取用户角色 + roles, err := c.permService.GetUserRoles(userID) + if err != nil { + utils.ServerError(ctx, "获取角色失败") + return + } + + // 获取用户权限 + permissions, err := c.permService.GetUserPermissions(userID) + if err != nil { + utils.ServerError(ctx, "获取权限失败") + return + } + + roleVOs := make([]dto.RoleVO, 0, len(roles)) + for _, r := range roles { + roleVOs = append(roleVOs, dto.RoleVO{ + ID: r.ID, + Name: r.Name, + DisplayName: r.DisplayName, + }) + } + + utils.Success(ctx, dto.UserPermissionVO{ + Roles: roleVOs, + Permissions: permissions, + }) +} diff --git a/server/controller/share_controller.go b/server/controller/share_controller.go new file mode 100644 index 0000000..1c03c1a --- /dev/null +++ b/server/controller/share_controller.go @@ -0,0 +1,201 @@ +package controller + +import ( + "net/http" + "strconv" + + "seeyon-filesystem/dto" + "seeyon-filesystem/middleware" + "seeyon-filesystem/service" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// ShareController 分享控制器 +type ShareController struct { + shareService *service.ShareService + fileService *service.FileService +} + +// NewShareController 创建分享控制器实例 +func NewShareController() *ShareController { + return &ShareController{ + shareService: service.NewShareService(), + fileService: service.NewFileService(), + } +} + +// Create 创建分享 +// POST /api/share +func (c *ShareController) Create(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + var req dto.ShareCreateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + result, err := c.shareService.Create(&req, ownerID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// ListMyShares 查看我的分享 +// GET /api/share +func (c *ShareController) ListMyShares(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + results, err := c.shareService.ListByOwner(ownerID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, results) +} + +// Cancel 取消分享 +// DELETE /api/share/:id +func (c *ShareController) Cancel(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的分享ID") + return + } + + if err := c.shareService.Cancel(id, ownerID); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "已取消分享", nil) +} + +// GetShareInfo 获取分享信息(公开) +// GET /api/s/:code +func (c *ShareController) GetShareInfo(ctx *gin.Context) { + code := ctx.Param("code") + + fileInfo, share, err := c.shareService.GetByCode(code) + if err != nil { + utils.NotFound(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{ + "file": fileInfo, + "has_password": share.Password != "", + "expire_at": share.ExpireAt, + }) +} + +// VerifySharePassword 验证分享密码 +// POST /api/s/:code/verify +func (c *ShareController) VerifySharePassword(ctx *gin.Context) { + code := ctx.Param("code") + + var req dto.ShareVerifyRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "请输入密码") + return + } + + _, share, err := c.shareService.GetByCode(code) + if err != nil { + utils.NotFound(ctx, err.Error()) + return + } + + if !c.shareService.VerifyPassword(share.ID, req.Password) { + utils.Unauthorized(ctx, "密码错误") + return + } + + utils.Success(ctx, gin.H{"verified": true}) +} + +// DownloadShareFile 下载分享文件 +// GET /api/s/:code/download +func (c *ShareController) DownloadShareFile(ctx *gin.Context) { + code := ctx.Param("code") + + _, share, err := c.shareService.GetByCode(code) + if err != nil { + utils.NotFound(ctx, err.Error()) + return + } + + // 检查下载权限 + if share.AllowDownload != 1 { + utils.Forbidden(ctx, "此分享不允许下载") + return + } + + // 验证密码 + if share.Password != "" { + password := ctx.Query("password") + if !c.shareService.VerifyPassword(share.ID, password) { + utils.Unauthorized(ctx, "需要密码") + return + } + } + + reader, file, err := c.fileService.DownloadByFileID(share.FileID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + defer reader.Close() + + ctx.Header("Content-Disposition", "attachment; filename=\""+file.Name+"\"") + ctx.Header("Content-Type", file.MimeType) + ctx.DataFromReader(http.StatusOK, file.Size, file.MimeType, reader, nil) +} + +// PreviewShareFile 预览分享文件(内联展示) +// GET /api/s/:code/preview +func (c *ShareController) PreviewShareFile(ctx *gin.Context) { + code := ctx.Param("code") + + _, share, err := c.shareService.GetByCode(code) + if err != nil { + utils.NotFound(ctx, err.Error()) + return + } + + // 检查预览权限 + if share.AllowPreview != 1 { + utils.Forbidden(ctx, "此分享不允许预览") + return + } + + // 验证密码 + if share.Password != "" { + password := ctx.Query("password") + if !c.shareService.VerifyPassword(share.ID, password) { + utils.Unauthorized(ctx, "需要密码") + return + } + } + + reader, file, err := c.fileService.DownloadByFileID(share.FileID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + defer reader.Close() + + // 内联展示 + ctx.Header("Content-Disposition", "inline; filename=\""+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) +} diff --git a/server/controller/sso_controller.go b/server/controller/sso_controller.go new file mode 100644 index 0000000..0f5056e --- /dev/null +++ b/server/controller/sso_controller.go @@ -0,0 +1,297 @@ +package controller + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "strconv" + + "seeyon-filesystem/dto" + "seeyon-filesystem/model" + "seeyon-filesystem/service" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// SSOController SSO控制器 +type SSOController struct { + ssoService *service.SSOService +} + +func NewSSOController() *SSOController { + return &SSOController{ + ssoService: service.NewSSOService(), + } +} + +// ========== 公开接口 (无需登录) ========== + +// GetProviders 获取启用的SSO提供商列表(登录页展示) +// GET /api/sso/providers +func (c *SSOController) GetProviders(ctx *gin.Context) { + providers, err := c.ssoService.ListActiveProviders() + if err != nil { + utils.ServerError(ctx, "获取SSO提供商失败") + return + } + + vos := make([]dto.SSOProviderVO, 0, len(providers)) + for _, p := range providers { + vos = append(vos, dto.SSOProviderVO{ + ID: p.ID, + Name: p.Name, + DisplayName: p.DisplayName, + Icon: p.Icon, + Type: p.Type, + }) + } + utils.Success(ctx, vos) +} + +// GetAuthURL 获取SSO授权跳转URL +// GET /api/sso/auth/url?provider_id=1 +func (c *SSOController) GetAuthURL(ctx *gin.Context) { + var req dto.SSOAuthURLRequest + if err := ctx.ShouldBindQuery(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + state, _ := generateState() + authURL, err := c.ssoService.GetAuthURL(req.ProviderID, state) + if err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{ + "auth_url": authURL, + "state": state, + }) +} + +// Callback 处理SSO回调(重定向到前端) +// GET /api/sso/callback?provider_id=1&code=xxx&state=xxx +func (c *SSOController) Callback(ctx *gin.Context) { + var req dto.SSOCallbackRequest + if err := ctx.ShouldBindQuery(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + result, err := c.ssoService.HandleCallback(req.ProviderID, req.Code) + if err != nil { + // 回调失败, 重定向到登录页并携带错误信息 + ctx.Redirect(http.StatusFound, "/login?sso_error="+err.Error()) + return + } + + ctx.Redirect(http.StatusFound, fmt.Sprintf("/login?sso=success&token=%s&username=%s", result.Token, result.Username)) +} + +// CallbackJSON 处理SSO回调(JSON版本, 供前端AJAX调用) +// POST /api/sso/callback +func (c *SSOController) CallbackJSON(ctx *gin.Context) { + var req dto.SSOCallbackRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + result, err := c.ssoService.HandleCallback(req.ProviderID, req.Code) + if err != nil { + utils.Unauthorized(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// ========== 管理员接口 (需要登录 + admin权限) ========== + +// AdminListProviders 管理员获取所有SSO提供商 +// GET /api/admin/sso/providers +func (c *SSOController) AdminListProviders(ctx *gin.Context) { + providers, err := c.ssoService.ListProviders() + if err != nil { + utils.ServerError(ctx, "获取提供商列表失败") + return + } + utils.Success(ctx, providers) +} + +// AdminGetProvider 获取单个SSO提供商详情 +// GET /api/admin/sso/providers/:id +func (c *SSOController) AdminGetProvider(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的提供商ID") + return + } + + providers, _ := c.ssoService.ListProviders() + for _, p := range providers { + if p.ID == id { + utils.Success(ctx, p) + return + } + } + utils.NotFound(ctx, "提供商不存在") +} + +// AdminCreateProvider 管理员创建SSO提供商 +// POST /api/admin/sso/providers +func (c *SSOController) AdminCreateProvider(ctx *gin.Context) { + var req dto.SSOProviderCreateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + provider := &model.SSOProvider{ + Name: req.Name, + Type: req.Type, + DisplayName: req.DisplayName, + Icon: req.Icon, + AutoCreate: req.AutoCreate, + DefaultRole: req.DefaultRole, + Status: 1, + Config: model.SSOConfig{ + ClientID: req.Config.ClientID, + ClientSecret: req.Config.ClientSecret, + AuthURL: req.Config.AuthURL, + TokenURL: req.Config.TokenURL, + UserInfoURL: req.Config.UserInfoURL, + RedirectURI: req.Config.RedirectURI, + Scopes: req.Config.Scopes, + CasServerURL: req.Config.CasServerURL, + LDAPServer: req.Config.LDAPServer, + LDAPPort: req.Config.LDAPPort, + LDAPBaseDN: req.Config.LDAPBaseDN, + LDAPBindUser: req.Config.LDAPBindUser, + LDAPBindPass: req.Config.LDAPBindPass, + LDAPFilter: req.Config.LDAPFilter, + MappingUsername: req.Config.MappingUsername, + MappingEmail: req.Config.MappingEmail, + MappingNickname: req.Config.MappingNickname, + }, + } + + if err := c.ssoService.CreateProvider(provider); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "创建成功", nil) +} + +// AdminUpdateProvider 管理员更新SSO提供商 +// PUT /api/admin/sso/providers/:id +func (c *SSOController) AdminUpdateProvider(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的提供商ID") + return + } + + // 获取现有提供商 + providers, _ := c.ssoService.ListProviders() + var existing *model.SSOProvider + for i, p := range providers { + if p.ID == id { + existing = &providers[i] + break + } + } + if existing == nil { + utils.NotFound(ctx, "提供商不存在") + return + } + + var req dto.SSOProviderCreateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + // 更新字段 + if req.Name != "" { existing.Name = req.Name } + if req.DisplayName != "" { existing.DisplayName = req.DisplayName } + if req.Icon != "" { existing.Icon = req.Icon } + if req.DefaultRole != "" { existing.DefaultRole = req.DefaultRole } + if req.AutoCreate != 0 { existing.AutoCreate = req.AutoCreate } + + // 更新配置 + if req.Config.ClientID != "" { existing.Config.ClientID = req.Config.ClientID } + if req.Config.ClientSecret != "" { existing.Config.ClientSecret = req.Config.ClientSecret } + if req.Config.AuthURL != "" { existing.Config.AuthURL = req.Config.AuthURL } + if req.Config.TokenURL != "" { existing.Config.TokenURL = req.Config.TokenURL } + if req.Config.UserInfoURL != "" { existing.Config.UserInfoURL = req.Config.UserInfoURL } + if req.Config.RedirectURI != "" { existing.Config.RedirectURI = req.Config.RedirectURI } + if req.Config.Scopes != "" { existing.Config.Scopes = req.Config.Scopes } + if req.Config.CasServerURL != "" { existing.Config.CasServerURL = req.Config.CasServerURL } + + if err := c.ssoService.UpdateProvider(existing); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "更新成功", nil) +} + +// AdminDeleteProvider 管理员删除SSO提供商 +// DELETE /api/admin/sso/providers/:id +func (c *SSOController) AdminDeleteProvider(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的提供商ID") + return + } + + if err := c.ssoService.DeleteProvider(id); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "删除成功", nil) +} + +// AdminToggleProvider 启用/禁用SSO提供商 +// PUT /api/admin/sso/providers/:id/toggle +func (c *SSOController) AdminToggleProvider(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的提供商ID") + return + } + + var req struct { + Status int8 `json:"status"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + providers, _ := c.ssoService.ListProviders() + for i, p := range providers { + if p.ID == id { + providers[i].Status = req.Status + if err := c.ssoService.UpdateProvider(&providers[i]); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "操作成功", nil) + return + } + } + utils.NotFound(ctx, "提供商不存在") +} + +// generateState 生成随机state参数(防CSRF) +func generateState() (string, error) { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} diff --git a/server/docs/api.md b/server/docs/api.md new file mode 100644 index 0000000..93f280d --- /dev/null +++ b/server/docs/api.md @@ -0,0 +1,894 @@ +# 文件管理系统 OpenAPI 接口文档 + +> Base URL: `http://localhost:8080/api` +> +> 认证方式: `Authorization: Bearer ` +> +> 统一响应格式: `{ "code": 0, "message": "success", "data": ... }` + +--- + +## 1. 认证 (Auth) + +### 1.1 用户登录 +``` +POST /api/auth/login +``` +**请求体:** +```json +{ "username": "admin", "password": "admin123" } +``` +**响应:** +```json +{ + "code": 0, + "data": { + "token": "eyJhbGci...", + "username": "admin", + "nickname": "管理员", + "role": "admin" + } +} +``` + +### 1.2 用户注册 +``` +POST /api/auth/register +``` +**请求体:** +```json +{ "username": "user1", "password": "123456", "nickname": "用户1", "email": "user1@example.com" } +``` + +### 1.3 获取当前用户信息 +``` +GET /api/auth/profile +Authorization: Bearer +``` + +### 1.4 获取当前用户权限 +``` +GET /api/auth/permissions +Authorization: Bearer +``` +**响应:** +```json +{ + "code": 0, + "data": { + "roles": [{ "id": 1, "name": "admin", "display_name": "管理员" }], + "permissions": ["file:create", "file:read", "file:update", "file:delete", "file:upload", "share:create"] + } +} +``` + +--- + +## 2. 文件管理 (File) + +### 2.1 上传文件(简单上传) +``` +POST /api/file/upload?folder_id=1&storage_policy_id=1 +Content-Type: multipart/form-data +Authorization: Bearer +``` +**参数:** +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| file | File | 是 | 文件内容 | +| folder_id | int | 否 | 目标文件夹ID, 0=根目录 | +| storage_policy_id | int | 否 | 存储策略ID, 0=使用默认策略 | + +**响应:** +```json +{ + "code": 0, + "data": { + "id": 1, + "name": "test.txt", + "extension": "txt", + "folder_id": 1, + "size": 1024, + "storage_key": "2026/07/02/ab/abcdef123456.txt", + "storage_policy_id": 1, + "mime_type": "text/plain" + } +} +``` + +### 2.2 分片上传(大文件/断点续传) + +**Step 1: 初始化上传** +``` +POST /api/file/upload/init +Authorization: Bearer +``` +**请求体:** +```json +{ + "file_name": "large_video.mp4", + "file_size": 104857600, + "md5": "d41d8cd98f00b204e9800998ecf8427e", + "folder_id": 1, + "policy_id": 1 +} +``` +**响应:** +```json +{ + "code": 0, + "data": { + "upload_id": "uuid-xxx", + "instant": false, + "chunk_size": 5242880, + "total": 20 + } +} +``` +> 如果 `instant: true`, 表示文件已存在(秒传), 直接使用 `file_id` + +**Step 2: 逐片上传** +``` +PUT /api/file/upload/chunk/:uploadId/:index +Content-Type: multipart/form-data +Authorization: Bearer +X-Checksum: sha256_of_chunk +``` +**表单字段:** `chunk` = 分片二进制数据 + +**Step 3: 查询进度(断点续传)** +``` +GET /api/file/upload/progress/:uploadId +Authorization: Bearer +``` +**响应:** +```json +{ + "code": 0, + "data": { + "upload_id": "uuid-xxx", + "total_chunks": 20, + "uploaded": 15, + "missing": [15, 16, 17, 18, 19], + "status": 1 + } +} +``` +> `missing` 数组列出未上传的分片索引, 客户端只需重传这些分片 + +**Step 4: 合并完成** +``` +POST /api/file/upload/merge/:uploadId +Authorization: Bearer +``` + +**Step 5: 取消上传** +``` +DELETE /api/file/upload/:uploadId +Authorization: Bearer +``` + +### 2.3 获取文件信息 +``` +GET /api/file/:id +Authorization: Bearer +``` + +### 2.3 根据 fileKey 查询文件信息 +``` +GET /api/file/info?key=2026/07/02/ab/abcdef123456.txt +Authorization: Bearer +``` +**参数:** +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| key | string | 是 | 文件的 storage_key | + +### 2.4 直接下载文件流 +``` +GET /api/file/:id/download +Authorization: Bearer +``` +**响应:** 文件二进制流, Content-Disposition: attachment + +### 2.5 获取下载链接 +``` +GET /api/file/:id/download-url +Authorization: Bearer +``` +**响应:** +```json +{ + "code": 0, + "data": { + "download_url": "http://localhost:9000/seeyon-fs/2026/07/02/...?X-Amz-..." + } +} +``` +> MinIO存储返回预签名URL(24小时有效), 本地存储返回API路径 + +### 2.6 预览文件流 +``` +GET /api/file/:id/preview +Authorization: Bearer +``` +**响应:** 文件二进制流, Content-Disposition: inline + +### 2.7 获取预览链接 +``` +GET /api/file/:id/preview-url +Authorization: Bearer +``` +**响应:** +```json +{ + "code": 0, + "data": { + "preview_url": "http://localhost:9000/seeyon-fs/2026/07/02/..." + } +} +``` + +### 2.8 重命名文件 +``` +PUT /api/file/:id/rename +Authorization: Bearer +``` +**请求体:** +```json +{ "new_name": "新文件名.txt" } +``` + +### 2.9 移动文件 +``` +POST /api/file/:id/move +Authorization: Bearer +``` +**请求体:** +```json +{ "target_folder_id": 5 } +``` + +### 2.10 删除文件(移入回收站) +``` +DELETE /api/file/:id +Authorization: Bearer +``` + +### 2.11 恢复文件 +``` +POST /api/file/:id/restore +Authorization: Bearer +``` + +### 2.12 永久删除文件 +``` +DELETE /api/file/:id/permanent +Authorization: Bearer +``` + +### 2.13 搜索文件 +``` +GET /api/file/search?keyword=报告 +Authorization: Bearer +``` + +### 2.14 获取回收站列表 +``` +GET /api/file/trash +Authorization: Bearer +``` + +--- + +## 3. 文件夹管理 (Folder) + +### 3.1 创建文件夹 +``` +POST /api/folder +Authorization: Bearer +``` +**请求体:** +```json +{ "name": "项目文档", "parent_id": 0 } +``` +**响应:** +```json +{ + "code": 0, + "data": { "id": 1, "name": "项目文档", "parent_id": 0, "path": "/1/" } +} +``` + +### 3.2 获取文件夹内容(文件+子文件夹) +``` +GET /api/folder/:id/children +Authorization: Bearer +``` +> `:id` 为 `root` 时获取根目录 + +**响应:** +```json +{ + "code": 0, + "data": { + "folders": [{ "id": 2, "name": "子文件夹", "parent_id": 1 }], + "files": [{ "id": 1, "name": "test.txt", "size": 1024 }] + } +} +``` + +### 3.3 重命名文件夹 +``` +PUT /api/folder/:id/rename +Authorization: Bearer +``` +**请求体:** +```json +{ "new_name": "新名称" } +``` + +### 3.4 删除文件夹 +``` +DELETE /api/folder/:id +Authorization: Bearer +``` + +--- + +## 4. 分享管理 (Share) + +### 4.1 创建分享链接 +``` +POST /api/share +Authorization: Bearer +``` +**请求体:** +```json +{ + "file_id": 1, + "password": "123456", + "expire_hours": 24, + "max_download": 10 +} +``` +**响应:** +```json +{ + "code": 0, + "data": { + "id": 1, + "share_code": "a1b2c3d4e5f6g7h8", + "share_url": "/s/a1b2c3d4e5f6g7h8", + "has_password": true, + "expire_at": "2026-07-03T15:00:00Z", + "max_download": 10, + "download_count": 0 + } +} +``` + +### 4.2 获取我的分享列表 +``` +GET /api/share +Authorization: Bearer +``` + +### 4.3 取消分享 +``` +DELETE /api/share/:id +Authorization: Bearer +``` + +### 4.4 获取分享信息(公开) +``` +GET /api/s/:code +``` +**响应:** +```json +{ + "code": 0, + "data": { + "file": { "name": "test.txt", "size": 1024, "mime_type": "text/plain" }, + "has_password": true, + "expire_at": "2026-07-03T15:00:00Z" + } +} +``` + +### 4.5 验证分享密码 +``` +POST /api/s/:code/verify +``` +**请求体:** +```json +{ "password": "123456" } +``` + +### 4.6 下载分享文件 +``` +GET /api/s/:code/download?password=123456 +``` + +--- + +## 5. SSO 单点登录 + +### 5.1 获取SSO提供商列表(公开) +``` +GET /api/sso/providers +``` +**响应:** +```json +{ + "code": 0, + "data": [ + { "id": 1, "name": "wechat", "display_name": "企业微信", "type": "oauth2", "icon": "" }, + { "id": 2, "name": "dingtalk", "display_name": "钉钉", "type": "oauth2", "icon": "" } + ] +} +``` + +### 5.2 获取SSO授权跳转URL +``` +GET /api/sso/auth/url?provider_id=1 +``` +**响应:** +```json +{ + "code": 0, + "data": { + "auth_url": "https://open.work.weixin.qq.com/wwopen/sso/qrConnect?...", + "state": "random_csrf_state" + } +} +``` + +### 5.3 SSO回调(重定向) +``` +GET /api/sso/callback?provider_id=1&code=AUTH_CODE&state=xxx +``` +> 第三方授权后回调, 自动重定向到前端: `/login?sso=success&token=xxx&username=xxx` + +### 5.4 SSO回调(JSON) +``` +POST /api/sso/callback +``` +**请求体:** +```json +{ "provider_id": 1, "code": "AUTH_CODE" } +``` +**响应:** +```json +{ + "code": 0, + "data": { + "token": "eyJhbGci...", + "username": "sso_oauth2_zhangsan", + "nickname": "张三", + "role": "user" + } +} +``` + +### 5.5 获取SSO提供商详情(管理员) +``` +GET /api/admin/sso/providers/:id +Authorization: Bearer +``` + +### 5.6 创建SSO提供商(管理员) +``` +POST /api/admin/sso/providers +Authorization: Bearer +``` +**请求体:** +```json +{ + "name": "wechat", + "type": "oauth2", + "display_name": "企业微信", + "icon": "https://example.com/wechat.png", + "auto_create": 1, + "default_role": "viewer", + "config": { + "client_id": "ww123456", + "client_secret": "secret_xxx", + "auth_url": "https://open.work.weixin.qq.com/wwopen/sso/qrConnect", + "token_url": "https://qyapi.weixin.qq.com/cgi-bin/gettoken", + "user_info_url": "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo", + "redirect_uri": "http://localhost:8080/api/sso/callback", + "scopes": "snsapi_userinfo" + } +} +``` + +**支持的 SSO 类型:** +| type | 说明 | 必填 config 字段 | +|------|------|------------------| +| oauth2 | OAuth 2.0 | client_id, client_secret, auth_url, token_url, user_info_url, redirect_uri | +| oidc | OpenID Connect | 同 oauth2 + issuer_url | +| cas | CAS | cas_server_url, redirect_uri | +| ldap | LDAP | ldap_server, ldap_port, ldap_base_dn, ldap_bind_user, ldap_bind_pass | + +### 5.7 更新SSO提供商(管理员) +``` +PUT /api/admin/sso/providers/:id +Authorization: Bearer +``` +> 请求体同创建, 字段可选(只传需要修改的字段) + +### 5.8 启用/禁用SSO提供商(管理员) +``` +PUT /api/admin/sso/providers/:id/toggle +Authorization: Bearer +``` +**请求体:** +```json +{ "status": 1 } +``` + +### 5.9 删除SSO提供商(管理员) +``` +DELETE /api/admin/sso/providers/:id +Authorization: Bearer +``` + +--- + +## 6. 用户管理 (Admin) + +### 6.1 用户列表 +``` +GET /api/admin/users?page=1&size=20 +Authorization: Bearer +``` + +### 6.2 创建用户 +``` +POST /api/admin/users +Authorization: Bearer +``` +**请求体:** +```json +{ + "username": "user1", + "password": "123456", + "nickname": "用户1", + "email": "user1@example.com", + "role_ids": [2], + "storage_quota": 10737418240 +} +``` + +### 6.3 更新用户 +``` +PUT /api/admin/users/:id +Authorization: Bearer +``` + +### 6.4 删除用户 +``` +DELETE /api/admin/users/:id +Authorization: Bearer +``` + +### 6.5 重置密码 +``` +PUT /api/admin/users/:id/password +Authorization: Bearer +``` +**请求体:** +```json +{ "password": "new_password" } +``` + +### 6.6 设置用户角色 +``` +PUT /api/admin/users/:id/roles +Authorization: Bearer +``` +**请求体:** +```json +{ "role_ids": [1, 2] } +``` + +--- + +## 7. 角色权限管理 (Admin) + +### 7.1 角色列表 +``` +GET /api/admin/roles +Authorization: Bearer +``` + +### 7.2 创建角色 +``` +POST /api/admin/roles +Authorization: Bearer +``` +**请求体:** +```json +{ "name": "editor", "display_name": "编辑者", "description": "可编辑文件" } +``` + +### 7.3 更新角色 +``` +PUT /api/admin/roles/:id +Authorization: Bearer +``` + +### 7.4 删除角色 +``` +DELETE /api/admin/roles/:id +Authorization: Bearer +``` + +### 7.5 设置角色权限 +``` +PUT /api/admin/roles/:id/permissions +Authorization: Bearer +``` +**请求体:** +```json +{ "permission_ids": [1, 2, 3, 4, 5] } +``` + +### 7.6 权限列表 +``` +GET /api/admin/permissions +Authorization: Bearer +``` + +### 7.7 创建权限 +``` +POST /api/admin/permissions +Authorization: Bearer +``` +**请求体:** +```json +{ "code": "file:download", "name": "下载文件", "resource": "file", "action": "download" } +``` + +### 7.8 删除权限 +``` +DELETE /api/admin/permissions/:id +Authorization: Bearer +``` + +--- + +## 8. 存储策略管理 (Admin) + +### 8.1 存储策略列表 +``` +GET /api/admin/storage-policies +Authorization: Bearer +``` + +### 8.2 创建存储策略 +``` +POST /api/admin/storage-policies +Authorization: Bearer +``` +**请求体 (本地存储):** +```json +{ "name": "本地存储", "type": "local", "config": { "base_path": "./data/storage" } } +``` +**请求体 (MinIO):** +```json +{ + "name": "MinIO存储", + "type": "minio", + "config": { + "endpoint": "localhost:9000", + "access_key": "admin", + "secret_key": "123456789", + "bucket": "seeyon-fs", + "use_ssl": false + } +} +``` + +### 8.3 更新存储策略 +``` +PUT /api/admin/storage-policies/:id +Authorization: Bearer +``` + +### 8.4 删除存储策略 +``` +DELETE /api/admin/storage-policies/:id +Authorization: Bearer +``` + +--- + +## 9. 组织架构管理 (Admin) + +### 9.1 部门列表(树形) +``` +GET /api/admin/departments +Authorization: Bearer +``` +**响应:** +```json +{ + "code": 0, + "data": [ + { + "id": 1, "name": "总公司", "code": "HQ", "parent_id": 0, + "children": [ + { "id": 2, "name": "技术部", "code": "TECH", "parent_id": 1 }, + { "id": 3, "name": "产品部", "code": "PM", "parent_id": 1 } + ] + } + ] +} +``` + +### 9.2 创建部门 +``` +POST /api/admin/departments +Authorization: Bearer +``` +**请求体:** +```json +{ "name": "技术部", "code": "TECH", "parent_id": 1, "sort_order": 1 } +``` + +### 9.3 更新部门 +``` +PUT /api/admin/departments/:id +Authorization: Bearer +``` + +### 9.4 删除部门 +``` +DELETE /api/admin/departments/:id +Authorization: Bearer +``` + +--- + +## 10. 系统设置 + +### 10.1 健康检查 +``` +GET /health +``` +**响应:** `{ "status": "ok" }` + +### 10.2 获取CDN配置(管理员) +``` +GET /api/admin/settings/cdn +Authorization: Bearer +``` +**响应:** +```json +{ + "code": 0, + "data": { + "cdn_host": "http://localhost:3090", + "cdn_path_prefix": "/public", + "local_base_path": "D:/Seeyon/A8/localfile" + } +} +``` + +### 10.3 更新CDN配置(管理员) +``` +PUT /api/admin/settings/cdn +Authorization: Bearer +``` +**请求体:** +```json +{ + "cdn_host": "http://localhost:3090", + "cdn_path_prefix": "/public" +} +``` +**响应:** +```json +{ + "code": 0, + "message": "CDN配置已更新", + "data": { + "cdn_host": "http://localhost:3090", + "cdn_path_prefix": "/public", + "example_url": "http://localhost:3090/public/2026/07/02/ab/abcdef.txt" + } +} +``` + +> 生成的链接格式: `cdn_host + cdn_path_prefix + / + storage_key` +> +> 例如: `http://localhost:3090/public/2026/07/02/30/306d8f65344c19bd.txt` + +--- + +## 权限标识一览 + +| 权限标识 | 说明 | +|----------|------| +| file:create | 创建文件/文件夹 | +| file:read | 查看文件 | +| file:update | 修改文件 | +| file:delete | 删除文件 | +| file:upload | 上传文件 | +| share:create | 创建分享 | +| share:manage | 管理分享 | +| user:manage | 用户管理 | +| role:manage | 角色管理 | +| sso:manage | SSO管理 | +| system:manage | 系统管理 | +| * | 所有权限(超级管理员) | + +### 2.17 统一文件访问(自动选择模式) +``` +GET /api/file/:id/access +Authorization: Bearer +``` +> 根据存储策略的 `access_mode` 自动选择: +> - `proxy`: 中转模式, 直接返回文件流 +> - `presigned`: 预签名模式, 302重定向到MinIO +> - `cdn`: CDN模式, 302重定向到CDN地址 + +### 2.18 获取文件访问URL +``` +GET /api/file/:id/access-url +Authorization: Bearer +``` +**响应:** +```json +{ + "code": 0, + "data": { + "mode": "presigned", + "url": "http://files.example.com/minio/seeyon-fs/2026/07/02/...?X-Amz-...", + "file_name": "test.pdf", + "file_size": 1024000, + "content_type": "application/pdf" + } +} +``` + +### 2.19 分享文件统一访问(公开) +``` +GET /api/s/:code/access?password=xxx +``` + +--- + +## 访问模式说明 + +| 模式 | 适用场景 | 工作原理 | +|------|----------|----------| +| `proxy` | 私有文件、严格权限 | Nginx→Server鉴权→Server从MinIO拉取→返回给用户 | +| `presigned` | 公开分享、大文件 | Server生成预签名URL→用户直连MinIO(经Nginx代理) | +| `cdn` | 静态资源、本地存储 | 返回CDN地址, 用户直接访问 | + +**预签名模式配置示例:** +```json +{ + "endpoint": "localhost:9000", + "bucket": "seeyon-fs", + "access_key": "admin", + "secret_key": "123456789", + "access_mode": "presigned", + "nginx_endpoint": "http://files.example.com" +} +``` +> `nginx_endpoint`: Nginx公网地址, 替换MinIO内部地址, 用户通过Nginx访问MinIO + +--- + +## 存储策略类型 + +| type | 说明 | config 字段 | +|------|------|-------------| +| local | 本地磁盘 | base_path | +| minio | MinIO | endpoint, access_key, secret_key, bucket, use_ssl | +| s3 | AWS S3 | endpoint, access_key, secret_key, bucket, region, use_ssl | +| oss | 阿里云 OSS | endpoint, access_key, secret_key, bucket, region | diff --git a/server/dto/admin_dto.go b/server/dto/admin_dto.go new file mode 100644 index 0000000..8eaa407 --- /dev/null +++ b/server/dto/admin_dto.go @@ -0,0 +1,247 @@ +package dto + +// ========== 用户管理 ========== + +// UserCreateRequest 创建用户请求 +type UserCreateRequest struct { + Username string `json:"username" binding:"required,min=3,max=64"` + Password string `json:"password"` // 可选, 留空则使用默认密码 + Email string `json:"email"` + Nickname string `json:"nickname"` + RoleIDs []uint64 `json:"role_ids"` // 角色ID列表 + DeptID uint64 `json:"dept_id"` // 部门ID + StorageQuota int64 `json:"storage_quota"` // 存储配额(字节) +} + +// UserUpdateRequest 更新用户请求 +type UserUpdateRequest struct { + Email string `json:"email"` + Nickname string `json:"nickname"` + Status *int8 `json:"status"` // 指针区分零值和未传 + RoleIDs []uint64 `json:"role_ids"` + DeptID uint64 `json:"dept_id"` + StorageQuota *int64 `json:"storage_quota"` +} + +// UserVO 用户视图对象(管理后台) +type UserVO struct { + ID uint64 `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Status int8 `json:"status"` + StorageQuota int64 `json:"storage_quota"` + UsedStorage int64 `json:"used_storage"` + DeptID uint64 `json:"dept_id"` + DeptName string `json:"dept_name"` + Roles []RoleVO `json:"roles"` + CreatedAt string `json:"created_at"` +} + +// ========== 存储策略管理 ========== + +// StoragePolicyCreateRequest 创建存储策略请求 +type StoragePolicyCreateRequest struct { + Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required,oneof=local minio s3 oss"` + PolicyType string `json:"policy_type" binding:"required,oneof=hot cold public slave"` // 热存储/冷存储/公开/扩展 + Config PolicyConfigDTO `json:"config" binding:"required"` +} + +// StoragePolicyUpdateRequest 更新存储策略请求 +type StoragePolicyUpdateRequest struct { + Name string `json:"name"` + Status *int8 `json:"status"` + Config *PolicyConfigDTO `json:"config"` +} + +// PolicyConfigDTO 存储策略配置 +type PolicyConfigDTO struct { + BasePath string `json:"base_path,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Bucket string `json:"bucket,omitempty"` + AccessKey string `json:"access_key,omitempty"` + SecretKey string `json:"secret_key,omitempty"` + Region string `json:"region,omitempty"` + UseSSL bool `json:"use_ssl,omitempty"` +} + +// StoragePolicyVO 存储策略视图对象 +type StoragePolicyVO struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // local/minio/s3/oss + PolicyType string `json:"policy_type"` // hot/cold/public/slave + Status int8 `json:"status"` + Config PolicyConfigDTO `json:"config"` + CreatedAt string `json:"created_at"` +} + +// StorageAssignmentVO 存储策略分配视图对象 +type StorageAssignmentVO struct { + ID uint64 `json:"id"` + PolicyID uint64 `json:"policy_id"` + PolicyName string `json:"policy_name"` + UserID uint64 `json:"user_id,omitempty"` + UserName string `json:"user_name,omitempty"` + GroupID uint64 `json:"group_id,omitempty"` + RoleName string `json:"role_name,omitempty"` + IsDefault int8 `json:"is_default"` + Priority int `json:"priority"` // 优先级, 越大越优先 + StorageQuota int64 `json:"storage_quota"` + AccessMode string `json:"access_mode"` + Status int8 `json:"status"` +} + +// StorageAssignRequest 分配存储策略请求 +type StorageAssignRequest struct { + PolicyID uint64 `json:"policy_id" binding:"required"` + UserID uint64 `json:"user_id"` + GroupID uint64 `json:"group_id"` + IsDefault int8 `json:"is_default"` + Priority int `json:"priority"` // 优先级 + StorageQuota int64 `json:"storage_quota"` + AccessMode string `json:"access_mode"` +} + +// 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"` +} + +// ========== 开放平台 ========== + +// AppVO 应用视图对象 +type AppVO struct { + ID uint64 `json:"id"` + AppID string `json:"app_id"` + AppSecret string `json:"app_secret,omitempty"` + AppName string `json:"app_name"` + Status int8 `json:"status"` + Users []AppUserVO `json:"users,omitempty"` // 绑定的用户 + CreatedAt string `json:"created_at"` +} + +// AppUserVO 应用绑定用户 +type AppUserVO struct { + UserID uint64 `json:"user_id"` + Username string `json:"username"` + Nickname string `json:"nickname"` +} + +// AppCreateRequest 创建应用请求 +type AppCreateRequest struct { + AppName string `json:"app_name" binding:"required"` +} + +// AppBindUsersRequest 绑定用户请求 +type AppBindUsersRequest struct { + UserIDs []uint64 `json:"user_ids" binding:"required"` +} + +// ========== 操作日志 ========== + +// OperationLogVO 操作日志视图对象 +type OperationLogVO struct { + ID uint64 `json:"id"` + UserID uint64 `json:"user_id"` + Username string `json:"username"` + Action string `json:"action"` + Resource string `json:"resource"` + Detail string `json:"detail"` + IP string `json:"ip"` + Status int `json:"status"` + CreatedAt string `json:"created_at"` +} + +// LogCleanRequest 日志清理请求 +type LogCleanRequest struct { + Days int `json:"days" binding:"required,min=1"` // 保留最近N天 +} + +// ========== 备份管理 ========== + +// BackupPolicyCreateRequest 创建备份策略请求 +type BackupPolicyCreateRequest struct { + Name string `json:"name" binding:"required"` + BackupType string `json:"backup_type" binding:"required,oneof=full incremental"` + TargetType string `json:"target_type" binding:"required,oneof=local minio"` + TargetConfig string `json:"target_config"` // JSON字符串 + IncludeDB int8 `json:"include_db"` + IncludeFiles int8 `json:"include_files"` + CronExpr string `json:"cron_expr"` + MaxBackups int `json:"max_backups"` +} + +// BackupPolicyVO 备份策略视图 +type BackupPolicyVO struct { + ID uint64 `json:"id"` + Name string `json:"name"` + BackupType string `json:"backup_type"` + TargetType string `json:"target_type"` + TargetConfig string `json:"target_config"` + IncludeDB int8 `json:"include_db"` + IncludeFiles int8 `json:"include_files"` + CronExpr string `json:"cron_expr"` + MaxBackups int `json:"max_backups"` + Status int8 `json:"status"` + LastRunAt *string `json:"last_run_at"` + NextRunAt *string `json:"next_run_at"` + CreatedAt string `json:"created_at"` +} + +// BackupLogVO 备份日志视图 +type BackupLogVO struct { + ID uint64 `json:"id"` + PolicyID uint64 `json:"policy_id"` + PolicyName string `json:"policy_name"` + BackupType string `json:"backup_type"` + Status string `json:"status"` + FilePath string `json:"file_path"` + FileSize int64 `json:"file_size"` + DBSize int64 `json:"db_size"` + FilesCount int `json:"files_count"` + Duration int `json:"duration"` + ErrorMsg string `json:"error_msg"` + StartedAt string `json:"started_at"` + FinishedAt *string `json:"finished_at"` +} + +// ========== 组织架构 ========== + +// DeptCreateRequest 创建部门请求 +type DeptCreateRequest struct { + Name string `json:"name" binding:"required,max=128"` + Code string `json:"code" binding:"max=64"` + ParentID uint64 `json:"parent_id"` + LeaderID uint64 `json:"leader_id"` + SortOrder int `json:"sort_order"` +} + +// DeptUpdateRequest 更新部门请求 +type DeptUpdateRequest struct { + Name string `json:"name" binding:"required,max=128"` + Code string `json:"code"` + LeaderID uint64 `json:"leader_id"` + SortOrder int `json:"sort_order"` + Status *int8 `json:"status"` +} + +// DeptVO 部门视图对象 +type DeptVO struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + ParentID uint64 `json:"parent_id"` + Path string `json:"path"` + LeaderID uint64 `json:"leader_id"` + SortOrder int `json:"sort_order"` + Status int8 `json:"status"` + Children []DeptVO `json:"children,omitempty"` // 子部门 +} diff --git a/server/dto/auth_dto.go b/server/dto/auth_dto.go new file mode 100644 index 0000000..ae07354 --- /dev/null +++ b/server/dto/auth_dto.go @@ -0,0 +1,31 @@ +// Package dto 定义请求和响应的数据传输对象 +package dto + +// LoginRequest 登录请求 +type LoginRequest struct { + Username string `json:"username" binding:"required"` // 用户名, 必填 + Password string `json:"password" binding:"required"` // 密码, 必填 +} + +// RegisterRequest 注册请求 +type RegisterRequest struct { + Username string `json:"username" binding:"required,min=3,max=64"` // 用户名, 3-64字符 + Password string `json:"password" binding:"required,min=6,max=128"` // 密码, 6-128字符 + Email string `json:"email" binding:"omitempty,email"` // 邮箱, 可选 + Nickname string `json:"nickname" binding:"omitempty,max=64"` // 昵称, 可选 +} + +// LoginResponse 登录响应 +type LoginResponse struct { + Token string `json:"token"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Role string `json:"role"` + MustChangePwd bool `json:"must_change_pwd"` // 首次登录需改密码 +} + +// ChangePasswordRequest 修改密码请求 +type ChangePasswordRequest struct { + OldPassword string `json:"old_password" binding:"required"` // 当前密码 + NewPassword string `json:"new_password" binding:"required,min=6"` // 新密码 +} diff --git a/server/dto/file_dto.go b/server/dto/file_dto.go new file mode 100644 index 0000000..5ff7a91 --- /dev/null +++ b/server/dto/file_dto.go @@ -0,0 +1,136 @@ +package dto + +import "time" + +// FileVO 文件视图对象 - 返回给前端 +type FileVO struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Extension string `json:"extension"` + FolderID uint64 `json:"folder_id"` + Size int64 `json:"size"` + StorageKey string `json:"storage_key"` + StoragePolicyID uint64 `json:"storage_policy_id"` + MimeType string `json:"mime_type"` + IsFavorite int8 `json:"is_favorite"` + DownloadCount int `json:"download_count"` + Version int `json:"version"` // 乐观锁版本号 + DownloadURL string `json:"download_url,omitempty"` // CDN下载链接 + PreviewURL string `json:"preview_url,omitempty"` // CDN预览链接 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// FolderVO 文件夹视图对象 +type FolderVO struct { + ID uint64 `json:"id"` + Name string `json:"name"` + ParentID uint64 `json:"parent_id"` + Path string `json:"path"` + Version int `json:"version"` // 乐观锁版本号 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// FileListVO 文件列表响应 - 包含文件和文件夹 +type FileListVO struct { + Folders []FolderVO `json:"folders"` // 文件夹列表 + Files []FileVO `json:"files"` // 文件列表 +} + +// CreateFolderRequest 创建文件夹请求 +type CreateFolderRequest struct { + Name string `json:"name" binding:"required,max=256"` // 文件夹名称 + ParentID uint64 `json:"parent_id"` // 父文件夹ID, 0=根目录 +} + +// RenameRequest 重命名请求 +type RenameRequest struct { + NewName string `json:"new_name" binding:"required,max=256"` // 新名称 + Version int `json:"version"` // 乐观锁版本号(可选) +} + +// MoveRequest 移动请求 +type MoveRequest struct { + TargetFolderID uint64 `json:"target_folder_id" binding:"required"` // 目标文件夹ID +} + +// 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 +} + +// ShareVO 分享视图对象 +type ShareVO struct { + ID uint64 `json:"id"` + ShareCode string `json:"share_code"` + ShareURL string `json:"share_url"` + HasPassword bool `json:"has_password"` + ExpireAt *time.Time `json:"expire_at"` + MaxDownload int `json:"max_download"` + DownloadCount int `json:"download_count"` + AllowPreview int8 `json:"allow_preview"` + AllowDownload int8 `json:"allow_download"` + CreatedAt time.Time `json:"created_at"` +} + +// ShareVerifyRequest 验证分享密码请求 +type ShareVerifyRequest struct { + Password string `json:"password" binding:"required"` +} + +// ShareFileVO 分享文件视图对象(公开) +type ShareFileVO struct { + Name string `json:"name"` + Extension string `json:"extension"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` + PreviewURL string `json:"preview_url"` // 预览链接 + AllowPreview bool `json:"allow_preview"` + AllowDownload bool `json:"allow_download"` + CreatedAt time.Time `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"` + 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"` // 总分片数 +} + +// ChunkUploadChunkVO 单分片上传响应 +type ChunkUploadChunkVO struct { + Index int `json:"index"` + Size int64 `json:"size"` + Status string `json:"status"` // ok / exists +} + +// ChunkUploadProgressVO 上传进度响应 +type ChunkUploadProgressVO struct { + UploadID string `json:"upload_id"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + ChunkSize int64 `json:"chunk_size"` + TotalChunks int `json:"total_chunks"` + Uploaded int `json:"uploaded"` + Missing []int `json:"missing"` // 未上传的分片索引 + Status int8 `json:"status"` // 1=上传中 2=已完成 3=已取消 +} diff --git a/server/dto/permission_dto.go b/server/dto/permission_dto.go new file mode 100644 index 0000000..8d29b59 --- /dev/null +++ b/server/dto/permission_dto.go @@ -0,0 +1,60 @@ +package dto + +// RoleCreateRequest 创建角色请求 +type RoleCreateRequest struct { + Name string `json:"name" binding:"required,max=64"` // 角色标识 + DisplayName string `json:"display_name" binding:"required,max=64"` // 显示名称 + Description string `json:"description" binding:"max=256"` // 描述 +} + +// RoleUpdateRequest 更新角色请求 +type RoleUpdateRequest struct { + DisplayName string `json:"display_name" binding:"required,max=64"` + Description string `json:"description" binding:"max=256"` +} + +// RoleVO 角色视图对象 +type RoleVO struct { + ID uint64 `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + IsSystem int8 `json:"is_system"` + Status int8 `json:"status"` + Permissions []PermissionVO `json:"permissions,omitempty"` +} + +// PermissionCreateRequest 创建权限请求 +type PermissionCreateRequest struct { + Code string `json:"code" binding:"required,max=64"` // 权限标识: file:upload + Name string `json:"name" binding:"required,max=64"` // 显示名称 + Resource string `json:"resource" binding:"required"` // 资源类型 + Action string `json:"action" binding:"required"` // 操作类型 + Description string `json:"description" binding:"max=256"` +} + +// PermissionVO 权限视图对象 +type PermissionVO struct { + ID uint64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Resource string `json:"resource"` + Action string `json:"action"` + Description string `json:"description"` +} + +// SetRolePermissionsRequest 设置角色权限请求 +type SetRolePermissionsRequest struct { + PermissionIDs []uint64 `json:"permission_ids" binding:"required"` // 权限ID列表 +} + +// SetUserRolesRequest 设置用户角色请求 +type SetUserRolesRequest struct { + RoleIDs []uint64 `json:"role_ids" binding:"required"` // 角色ID列表 +} + +// UserPermissionVO 用户权限视图对象 +type UserPermissionVO struct { + Roles []RoleVO `json:"roles"` // 用户角色列表 + Permissions []string `json:"permissions"` // 权限标识列表 +} diff --git a/server/dto/sso_dto.go b/server/dto/sso_dto.go new file mode 100644 index 0000000..533ef63 --- /dev/null +++ b/server/dto/sso_dto.go @@ -0,0 +1,61 @@ +package dto + +// SSOAuthURLRequest 获取SSO授权URL请求 +type SSOAuthURLRequest struct { + ProviderID uint64 `json:"provider_id" binding:"required"` // SSO提供商ID + State string `json:"state"` // 防CSRF状态码 +} + +// SSOCallbackRequest SSO回调请求 +type SSOCallbackRequest struct { + ProviderID uint64 `json:"provider_id" binding:"required"` // SSO提供商ID + Code string `json:"code" binding:"required"` // 授权码/CAS票据 +} + +// SSOProviderVO SSO提供商视图对象(登录页展示) +type SSOProviderVO struct { + ID uint64 `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Icon string `json:"icon"` + Type string `json:"type"` +} + +// SSOProviderCreateRequest 创建SSO提供商请求 +type SSOProviderCreateRequest struct { + Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required,oneof=oauth2 oidc cas ldap"` + DisplayName string `json:"display_name" binding:"required"` + Icon string `json:"icon"` + Config SSOConfigDTO `json:"config" binding:"required"` + AutoCreate int8 `json:"auto_create"` + DefaultRole string `json:"default_role"` +} + +// SSOConfigDTO SSO配置DTO +type SSOConfigDTO struct { + // OAuth2/OIDC + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AuthURL string `json:"auth_url"` + TokenURL string `json:"token_url"` + UserInfoURL string `json:"user_info_url"` + RedirectURI string `json:"redirect_uri"` + Scopes string `json:"scopes"` + + // CAS + CasServerURL string `json:"cas_server_url"` + + // LDAP + LDAPServer string `json:"ldap_server"` + LDAPPort int `json:"ldap_port"` + LDAPBaseDN string `json:"ldap_base_dn"` + LDAPBindUser string `json:"ldap_bind_user"` + LDAPBindPass string `json:"ldap_bind_pass"` + LDAPFilter string `json:"ldap_filter"` + + // 字段映射 + MappingUsername string `json:"mapping_username"` + MappingEmail string `json:"mapping_email"` + MappingNickname string `json:"mapping_nickname"` +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..0226891 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,69 @@ +module seeyon-filesystem + +go 1.25.0 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.2.0 + 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 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + 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/google/uuid v1.6.0 // indirect + github.com/hashicorp/hcl v1.0.0 // 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 + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + 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/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 + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + 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/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 +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..584fa77 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,198 @@ +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= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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/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/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= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +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.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/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/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= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +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/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= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.2.1 h1:PfBfwvKB/MmqyN8Vb1G9voWisaM9OrLv+WwOvMwS9Dw= +github.com/minio/minio-go/v7 v7.2.1/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +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/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/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.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= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +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.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= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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.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= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +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/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= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +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.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +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/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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +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= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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/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.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/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= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..b4322ac --- /dev/null +++ b/server/main.go @@ -0,0 +1,244 @@ +// Package main 程序入口 +package main + +import ( + "fmt" + "log" + + "seeyon-filesystem/api" + "seeyon-filesystem/config" + "seeyon-filesystem/model" + "seeyon-filesystem/storage" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("加载配置失败: %v", err) + } + + if err := config.InitDB(&cfg.Database); err != nil { + log.Fatalf("初始化数据库失败: %v", err) + } + + if err := autoMigrate(); err != nil { + log.Fatalf("数据库迁移失败: %v", err) + } + + initDefaultData() + + storage.InitEngines(cfg.Storage.LocalBasePath) + + r := api.SetupRouter() + + addr := fmt.Sprintf(":%d", cfg.Server.Port) + log.Printf("服务启动: http://localhost%s", addr) + + if err := r.Run(addr); err != nil { + log.Fatalf("服务启动失败: %v", err) + } +} + +func autoMigrate() error { + err := config.DB.AutoMigrate( + &model.User{}, + &model.Folder{}, + &model.File{}, + &model.Share{}, + &model.StoragePolicy{}, + &model.FileAssociation{}, + &model.Role{}, + &model.Permission{}, + &model.RolePermission{}, + &model.UserRole{}, + &model.SSOProvider{}, + &model.SSOSession{}, + &model.Department{}, + &model.UploadSession{}, + &model.StorageAssignment{}, + &model.App{}, + &model.AppUser{}, + &model.FilePermission{}, + &model.OperationLog{}, + &model.BackupPolicy{}, + &model.BackupLog{}, + ) + if err != nil { + return err + } + + // 添加复合索引优化查询性能 + createIndexIfNotExists("fs_file_permission", "idx_fp_user_file", "user_id, file_id") + createIndexIfNotExists("fs_file_permission", "idx_fp_role_file", "role_id, file_id") + createIndexIfNotExists("fs_file", "idx_file_folder_owner", "folder_id, owner_id, status") + createIndexIfNotExists("fs_file", "idx_file_status", "status") + + return nil +} + +// createIndexIfNotExists 安全创建索引(忽略已存在错误) +func createIndexIfNotExists(table, indexName, columns string) { + sql := fmt.Sprintf("CREATE INDEX %s ON %s(%s)", indexName, table, columns) + config.DB.Exec(sql) +} + +// initDefaultData 初始化默认数据: 权限、角色、存储策略、分配规则 +func initDefaultData() { + // ========== 1. 权限 ========== + permissions := []model.Permission{ + {Code: "file:create", Name: "创建文件/文件夹", Resource: "file", Action: "create"}, + {Code: "file:read", Name: "查看文件", Resource: "file", Action: "read"}, + {Code: "file:update", Name: "修改文件", Resource: "file", Action: "update"}, + {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: "share:create", Name: "创建分享", Resource: "share", Action: "create"}, + {Code: "share:manage", Name: "管理分享", Resource: "share", Action: "manage"}, + {Code: "webdav:access", Name: "WebDAV访问", Resource: "webdav", Action: "access"}, + {Code: "user:manage", Name: "用户管理", Resource: "user", Action: "manage"}, + {Code: "role:manage", Name: "角色管理", Resource: "role", Action: "manage"}, + {Code: "sso:manage", Name: "SSO管理", Resource: "sso", Action: "manage"}, + {Code: "system:manage", Name: "系统管理", Resource: "system", Action: "manage"}, + } + for _, p := range permissions { + config.DB.Where("code = ?", p.Code).FirstOrCreate(&p) + } + + // ========== 2. 四级角色 ========== + roles := []model.Role{ + {Name: "super_admin", DisplayName: "超级管理员", Description: "全平台最高权限, 仅用于后台运维", IsSystem: 1}, + {Name: "staff", DisplayName: "内部员工", Description: "团队内部成员, 可上传下载分享文件", IsSystem: 1}, + {Name: "user", DisplayName: "普通用户", Description: "注册用户, 基础存储功能", IsSystem: 1}, + {Name: "guest", DisplayName: "访客", Description: "仅可查看公开分享资源", IsSystem: 1}, + } + for _, r := range roles { + config.DB.Where("name = ?", r.Name).FirstOrCreate(&r) + } + + // 角色-权限分配 + rolePermMap := map[string][]string{ + "super_admin": {"*"}, // 全部权限 + "staff": {"file:create", "file:read", "file:update", "file:delete", "file:upload", "file:download", "share:create", "webdav:access"}, + "user": {"file:create", "file:read", "file:update", "file:upload", "file:download", "share:create"}, + "guest": {"file:read"}, + } + + var allPerms []model.Permission + config.DB.Find(&allPerms) + permMap := make(map[string]uint64) + for _, p := range allPerms { + permMap[p.Code] = p.ID + } + + for roleName, permCodes := range rolePermMap { + var role model.Role + config.DB.Where("name = ?", roleName).First(&role) + if role.ID == 0 { + continue + } + + // 清除旧权限 + config.DB.Where("role_id = ?", role.ID).Delete(&model.RolePermission{}) + + if len(permCodes) == 1 && permCodes[0] == "*" { + // 全部权限 + for _, p := range allPerms { + config.DB.Create(&model.RolePermission{RoleID: role.ID, PermissionID: p.ID}) + } + } else { + for _, code := range permCodes { + if pid, ok := permMap[code]; ok { + config.DB.Create(&model.RolePermission{RoleID: role.ID, PermissionID: pid}) + } + } + } + } + + // ========== 3. 四类存储策略 ========== + type policyDef struct { + Name string + PolicyType string + Type string + BasePath string + Quota int64 + } + policyDefs := []policyDef{ + {Name: "高性能热存储", PolicyType: "hot", Type: "local", BasePath: "./data/storage/hot", Quota: 53687091200}, // 50GB + {Name: "低成本冷存储", PolicyType: "cold", Type: "local", BasePath: "./data/storage/cold", Quota: 107374182400}, // 100GB + {Name: "公开资源存储", PolicyType: "public", Type: "local", BasePath: "./data/storage/public", Quota: 0}, // 不限 + {Name: "扩展存储", PolicyType: "slave", Type: "local", BasePath: "./data/storage/slave", Quota: 214748364800}, // 200GB + } + + for _, pd := range policyDefs { + var existing model.StoragePolicy + if config.DB.Where("policy_type = ?", pd.PolicyType).First(&existing).Error != nil { + config.DB.Create(&model.StoragePolicy{ + Name: pd.Name, + Type: pd.Type, + PolicyType: pd.PolicyType, + Config: model.PolicyConfig{BasePath: pd.BasePath}, + Status: 1, + }) + } + } + + // ========== 4. 角色-存储策略分配(支持多策略+优先级) ========== + type assignDef struct { + RoleName string + PolicyType string + Quota int64 + Priority int + } + assignDefs := []assignDef{ + {RoleName: "super_admin", PolicyType: "hot", Quota: 0, Priority: 10}, + {RoleName: "staff", PolicyType: "hot", Quota: 53687091200, Priority: 10}, + {RoleName: "user", PolicyType: "cold", Quota: 10737418240, Priority: 10}, + } + + for _, ad := range assignDefs { + var role model.Role + var policy model.StoragePolicy + config.DB.Where("name = ?", ad.RoleName).First(&role) + config.DB.Where("policy_type = ?", ad.PolicyType).First(&policy) + if role.ID == 0 || policy.ID == 0 { + continue + } + + var existing model.StorageAssignment + if config.DB.Where("group_id = ? AND policy_id = ?", role.ID, policy.ID).First(&existing).Error != nil { + config.DB.Create(&model.StorageAssignment{ + PolicyID: policy.ID, + GroupID: role.ID, + StorageQuota: ad.Quota, + Priority: ad.Priority, + Status: 1, + }) + } + } + + // 设置默认存储策略(热存储) + var hotPolicy model.StoragePolicy + if config.DB.Where("policy_type = ?", "hot").First(&hotPolicy).Error == nil { + var defaultAssign model.StorageAssignment + if config.DB.Where("is_default = 1").First(&defaultAssign).Error != nil { + config.DB.Create(&model.StorageAssignment{ + PolicyID: hotPolicy.ID, + IsDefault: 1, + Status: 1, + }) + } + } + + // ========== 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) + } + } + + log.Println("默认数据初始化完成") +} diff --git a/server/middleware/auth.go b/server/middleware/auth.go new file mode 100644 index 0000000..e8f2374 --- /dev/null +++ b/server/middleware/auth.go @@ -0,0 +1,158 @@ +package middleware + +import ( + "strings" + + "seeyon-filesystem/repository" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// JWTAuth JWT认证中间件 +// 从请求头 Authorization: Bearer 中提取并验证JWT +// 验证通过后将用户信息存入上下文 +func JWTAuth(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + utils.Unauthorized(c, "缺少Authorization请求头") + 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) + if err != nil { + utils.Unauthorized(c, "Token无效或已过期: "+err.Error()) + c.Abort() + return + } + + c.Set("user_id", claims.UserID) + c.Set("username", claims.Username) + c.Set("role", claims.Role) + + c.Next() + } +} + +// AdminAuth 管理员权限中间件 +// 检查用户是否具有admin角色(兼容旧的Role字段) +func AdminAuth() gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists || role.(string) != "admin" { + utils.Forbidden(c, "需要管理员权限") + c.Abort() + return + } + c.Next() + } +} + +// RequirePermission 权限检查中间件 +// 基于RBAC模型, 检查用户是否拥有指定权限 +// 用法: middleware.RequirePermission("file:upload") +func RequirePermission(permissionCode string) gin.HandlerFunc { + return func(c *gin.Context) { + userID := GetCurrentUserID(c) + if userID == 0 { + utils.Unauthorized(c, "未登录") + c.Abort() + return + } + + // 管理员拥有所有权限 + role, _ := c.Get("role") + if role == "admin" { + c.Next() + return + } + + // 通过RBAC查询权限 + permRepo := repository.NewPermissionRepository() + codes, err := permRepo.GetUserPermissionCodes(userID) + if err != nil { + utils.ServerError(c, "权限检查失败") + c.Abort() + return + } + + for _, code := range codes { + if code == permissionCode || code == "*" { + c.Next() + return + } + } + + utils.Forbidden(c, "缺少权限: "+permissionCode) + c.Abort() + } +} + +// RequireAnyPermission 权限检查中间件(满足任一即可) +// 用法: middleware.RequireAnyPermission("file:upload", "file:manage") +func RequireAnyPermission(permissionCodes ...string) gin.HandlerFunc { + return func(c *gin.Context) { + userID := GetCurrentUserID(c) + if userID == 0 { + utils.Unauthorized(c, "未登录") + c.Abort() + return + } + + role, _ := c.Get("role") + if role == "admin" { + c.Next() + return + } + + permRepo := repository.NewPermissionRepository() + codes, err := permRepo.GetUserPermissionCodes(userID) + if err != nil { + utils.ServerError(c, "权限检查失败") + c.Abort() + return + } + + codeSet := make(map[string]bool, len(codes)) + for _, code := range codes { + codeSet[code] = true + } + + for _, required := range permissionCodes { + if codeSet[required] || codeSet["*"] { + c.Next() + return + } + } + + utils.Forbidden(c, "权限不足") + c.Abort() + } +} + +// GetCurrentUserID 从上下文获取当前用户ID +func GetCurrentUserID(c *gin.Context) uint64 { + userID, exists := c.Get("user_id") + if !exists { + return 0 + } + return userID.(uint64) +} + +// GetCurrentUsername 从上下文获取当前用户名 +func GetCurrentUsername(c *gin.Context) string { + username, exists := c.Get("username") + if !exists { + return "" + } + return username.(string) +} diff --git a/server/middleware/cors.go b/server/middleware/cors.go new file mode 100644 index 0000000..124feab --- /dev/null +++ b/server/middleware/cors.go @@ -0,0 +1,36 @@ +// Package middleware 提供HTTP中间件 +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +// CORS 跨域资源共享中间件 +func CORS() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Requested-With") + c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Type") + c.Header("Access-Control-Max-Age", "86400") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} + +// UTF8ContentType 确保JSON响应使用UTF-8编码 +func UTF8ContentType() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + // 为JSON响应添加charset + contentType := c.Writer.Header().Get("Content-Type") + if contentType == "application/json" { + c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") + } + } +} diff --git a/server/model/department.go b/server/model/department.go new file mode 100644 index 0000000..ba0c98e --- /dev/null +++ b/server/model/department.go @@ -0,0 +1,22 @@ +package model + +import "time" + +// Department 部门/组织架构模型 +// 使用物化路径实现树形结构 +type Department struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" gorm:"size:128;not null"` // 部门名称 + Code string `json:"code" gorm:"uniqueIndex;size:64"` // 部门编码 + ParentID uint64 `json:"parent_id" gorm:"index;default:0"` // 上级部门ID, 0=顶级 + Path string `json:"path" gorm:"size:1024;not null"` // 物化路径 + LeaderID uint64 `json:"leader_id" gorm:"default:0"` // 部门负责人用户ID + SortOrder int `json:"sort_order" gorm:"default:0"` // 排序 + Status int8 `json:"status" gorm:"default:1"` // 1=启用 0=禁用 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Department) TableName() string { + return "fs_department" +} diff --git a/server/model/file.go b/server/model/file.go new file mode 100644 index 0000000..369b5af --- /dev/null +++ b/server/model/file.go @@ -0,0 +1,59 @@ +package model + +import "time" + +// File 文件模型 +type File struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + 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"` + OwnerID uint64 `json:"owner_id" gorm:"index;not null"` + Size int64 `json:"size" gorm:"default:0"` + MD5 string `json:"md5" gorm:"size:32;index"` + SHA256 string `json:"sha256" gorm:"size:64"` + StorageKey string `json:"storage_key" gorm:"size:512;not null"` + StoragePolicyID uint64 `json:"storage_policy_id" gorm:"index;default:1"` + MimeType string `json:"mime_type" gorm:"size:128"` + IsFavorite int8 `json:"is_favorite" gorm:"default:0"` + Status int8 `json:"status" gorm:"default:1"` + DownloadCount int `json:"download_count" gorm:"default:0"` + Version int `json:"version" gorm:"default:1"` // 乐观锁版本号 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName 指定表名 +func (File) TableName() string { + return "fs_file" +} + +// FileAssociation 业务文件关联模型 +// 用于兼容 OA 系统的文件关联 +type FileAssociation struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + BizID string `json:"biz_id" gorm:"size:128;index;not null"` + BizType string `json:"biz_type" gorm:"size:64;index;not null"` + FileID uint64 `json:"file_id" gorm:"not null"` + CreatedAt time.Time `json:"created_at"` +} + +// TableName 指定表名 +func (FileAssociation) TableName() string { + return "fs_file_association" +} + +// FilePermission 文件权限模型 +type FilePermission struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + FileID uint64 `json:"file_id" gorm:"not null"` + UserID uint64 `json:"user_id" gorm:"default:0"` + RoleID uint64 `json:"role_id" gorm:"default:0"` + PermLevel string `json:"perm_level" gorm:"size:16;default:read"` + GrantBy uint64 `json:"grant_by" gorm:"not null"` + CreatedAt time.Time `json:"created_at"` +} + +func (FilePermission) TableName() string { + return "fs_file_permission" +} diff --git a/server/model/folder.go b/server/model/folder.go new file mode 100644 index 0000000..84f467a --- /dev/null +++ b/server/model/folder.go @@ -0,0 +1,23 @@ +package model + +import "time" + +// Folder 文件夹模型 +// 使用物化路径(materialized path)实现树形结构 +// 例如: /0/1/5/ 表示根目录下ID=1的文件夹中的ID=5的文件夹 +type Folder struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + 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"` + Path string `json:"path" gorm:"size:1024;not null"` // 物化路径 + Status int8 `json:"status" gorm:"default:1"` // 1=正常 0=回收站 + Version int `json:"version" gorm:"default:1"` // 乐观锁版本号 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName 指定表名 +func (Folder) TableName() string { + return "fs_folder" +} diff --git a/server/model/role.go b/server/model/role.go new file mode 100644 index 0000000..283f68c --- /dev/null +++ b/server/model/role.go @@ -0,0 +1,59 @@ +package model + +import "time" + +// Role 角色模型 +// RBAC核心: 用户 -> 角色 -> 权限 +type Role struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" gorm:"uniqueIndex;size:64;not null"` // 角色标识: admin/editor/viewer + DisplayName string `json:"display_name" gorm:"size:64;not null"` // 显示名称: 管理员/编辑者/查看者 + Description string `json:"description" gorm:"size:256"` // 角色描述 + IsSystem int8 `json:"is_system" gorm:"default:0"` // 是否系统内置角色(不可删除) + Status int8 `json:"status" gorm:"default:1"` // 1=启用 0=禁用 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // 关联 + Permissions []Permission `json:"permissions,omitempty" gorm:"many2many:fs_role_permission;"` +} + +func (Role) TableName() string { + return "fs_role" +} + +// Permission 权限模型 +// 定义系统中所有可控制的操作权限 +type Permission struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Code string `json:"code" gorm:"uniqueIndex;size:64;not null"` // 权限标识: file:upload, file:delete, user:manage + Name string `json:"name" gorm:"size:64;not null"` // 显示名称: 上传文件, 删除文件, 用户管理 + Resource string `json:"resource" gorm:"size:32;not null"` // 资源类型: file, folder, share, user, system + Action string `json:"action" gorm:"size:32;not null"` // 操作类型: create, read, update, delete, manage + Description string `json:"description" gorm:"size:256"` +} + +func (Permission) TableName() string { + return "fs_permission" +} + +// RolePermission 角色-权限关联表 +type RolePermission struct { + RoleID uint64 `json:"role_id" gorm:"primaryKey"` + PermissionID uint64 `json:"permission_id" gorm:"primaryKey"` +} + +func (RolePermission) TableName() string { + return "fs_role_permission" +} + +// UserRole 用户-角色关联表 +// 一个用户可以有多个角色, 权限取并集 +type UserRole struct { + UserID uint64 `json:"user_id" gorm:"primaryKey"` + RoleID uint64 `json:"role_id" gorm:"primaryKey"` +} + +func (UserRole) TableName() string { + return "fs_user_role" +} diff --git a/server/model/share.go b/server/model/share.go new file mode 100644 index 0000000..f557ae6 --- /dev/null +++ b/server/model/share.go @@ -0,0 +1,23 @@ +package model + +import "time" + +// Share 文件分享模型 +type Share struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + FileID uint64 `json:"file_id" gorm:"not null"` + OwnerID uint64 `json:"owner_id" gorm:"not null"` + ShareCode string `json:"share_code" gorm:"uniqueIndex;size:32;not null"` + Password string `json:"password,omitempty" gorm:"size:64"` + ExpireAt *time.Time `json:"expire_at"` + MaxDownload int `json:"max_download"` + DownloadCount int `json:"download_count"` + AllowPreview int8 `json:"allow_preview"` // 1=允许预览 0=禁止预览 + AllowDownload int8 `json:"allow_download"` // 1=允许下载 0=禁止下载 + Status int8 `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +func (Share) TableName() string { + return "fs_share" +} diff --git a/server/model/sso.go b/server/model/sso.go new file mode 100644 index 0000000..e16faf9 --- /dev/null +++ b/server/model/sso.go @@ -0,0 +1,97 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "time" +) + +// SSOProvider SSO提供商配置 +// 支持 OAuth2.0 / CAS / LDAP / OIDC 等协议 +type SSOProvider struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" gorm:"size:64;not null"` // 提供商名称: 企业微信/钉钉/飞书 + Type string `json:"type" gorm:"size:32;not null"` // 协议类型: oauth2/cas/oidc/ldap + DisplayName string `json:"display_name" gorm:"size:64;not null"` // 登录页显示名称 + Icon string `json:"icon" gorm:"size:512"` // 图标URL + Config SSOConfig `json:"config" gorm:"type:json"` // 协议配置 + AutoCreate int8 `json:"auto_create" gorm:"default:1"` // 自动创建用户: 1=是 0=否 + DefaultRole string `json:"default_role" gorm:"size:32;default:viewer"` // 自动创建时的默认角色 + Status int8 `json:"status" gorm:"default:1"` // 1=启用 0=禁用 + SortOrder int `json:"sort_order" gorm:"default:0"` // 排序 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (SSOProvider) TableName() string { + return "fs_sso_provider" +} + +// SSOConfig SSO协议配置 +// 不同协议使用不同的配置字段 +type SSOConfig struct { + // OAuth2.0 / OIDC 通用配置 + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + AuthURL string `json:"auth_url,omitempty"` // 授权端点 + TokenURL string `json:"token_url,omitempty"` // Token端点 + UserInfoURL string `json:"user_info_url,omitempty"` // 用户信息端点 + RedirectURI string `json:"redirect_uri,omitempty"` // 回调地址 + Scopes string `json:"scopes,omitempty"` // 授权范围 + ResponseType string `json:"response_type,omitempty"` // response_type + + // OIDC 特有 + IssuerURL string `json:"issuer_url,omitempty"` // Issuer URL + + // CAS 特有 + CasServerURL string `json:"cas_server_url,omitempty"` // CAS服务器地址 + + // LDAP 特有 + LDAPServer string `json:"ldap_server,omitempty"` // LDAP服务器地址 + LDAPPort int `json:"ldap_port,omitempty"` // LDAP端口 + LDAPBaseDN string `json:"ldap_base_dn,omitempty"` // Base DN + LDAPBindUser string `json:"ldap_bind_user,omitempty"` // 绑定用户 + LDAPBindPass string `json:"ldap_bind_pass,omitempty"` // 绑定密码 + LDAPFilter string `json:"ldap_filter,omitempty"` // 用户过滤器 + + // 字段映射 (从SSO用户信息到本地用户的字段映射) + MappingUsername string `json:"mapping_username,omitempty"` // 用户名字段映射 + MappingEmail string `json:"mapping_email,omitempty"` // 邮箱字段映射 + MappingNickname string `json:"mapping_nickname,omitempty"` // 昵称字段映射 +} + +// Value 实现 driver.Valuer +func (c SSOConfig) Value() (driver.Value, error) { + return json.Marshal(c) +} + +// Scan 实现 sql.Scanner +func (c *SSOConfig) Scan(value interface{}) error { + if value == nil { + return nil + } + bytes, ok := value.([]byte) + if !ok { + return fmt.Errorf("SSOConfig.Scan: 无法将 %T 转换为 []byte", value) + } + return json.Unmarshal(bytes, c) +} + +// SSOSession SSO会话记录 +// 记录SSO登录的来源和关联信息 +type SSOSession struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + UserID uint64 `json:"user_id" gorm:"index;not null"` // 关联的本地用户ID + ProviderID uint64 `json:"provider_id" gorm:"not null"` // SSO提供商ID + ExternalID string `json:"external_id" gorm:"size:256;not null"` // 外部系统用户ID + AccessToken string `json:"-" gorm:"size:1024"` // SSO Access Token + RefreshToken string `json:"-" gorm:"size:1024"` // SSO Refresh Token + ExpiresAt time.Time `json:"expires_at"` // Token过期时间 + LastLoginAt time.Time `json:"last_login_at"` // 最近登录时间 + CreatedAt time.Time `json:"created_at"` +} + +func (SSOSession) TableName() string { + return "fs_sso_session" +} diff --git a/server/model/storage_policy.go b/server/model/storage_policy.go new file mode 100644 index 0000000..da167af --- /dev/null +++ b/server/model/storage_policy.go @@ -0,0 +1,64 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "time" +) + +// StoragePolicy 存储策略模型 +type StoragePolicy struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" gorm:"size:64;not null"` + Type string `json:"type" gorm:"size:32;not null"` // local/minio/s3/oss + PolicyType string `json:"policy_type" gorm:"size:32;default:hot"` // hot/cold/public/slave + Config PolicyConfig `json:"config" gorm:"type:json"` + Status int8 `json:"status" gorm:"default:1"` + CreatedAt time.Time `json:"created_at"` +} + +// TableName 指定表名 +func (StoragePolicy) TableName() string { + return "fs_storage_policy" +} + +// PolicyConfig 存储策略配置 +type PolicyConfig struct { + // 本地存储配置 + BasePath string `json:"base_path,omitempty"` + + // MinIO/S3/OSS 配置 + Endpoint string `json:"endpoint,omitempty"` + Bucket string `json:"bucket,omitempty"` + AccessKey string `json:"access_key,omitempty"` + SecretKey string `json:"secret_key,omitempty"` + Region string `json:"region,omitempty"` + UseSSL bool `json:"use_ssl,omitempty"` + + // 访问模式配置 + AccessMode string `json:"access_mode,omitempty"` // proxy=中转模式, presigned=预签名直连, 默认proxy + // Nginx代理MinIO的地址(预签名模式下, 替换MinIO内部地址为Nginx公网地址) + NginxEndpoint string `json:"nginx_endpoint,omitempty"` // 如 http://files.example.com + // 中转模式下是否启用X-Sendfile + UseXSendfile bool `json:"use_x_sendfile,omitempty"` + // X-Sendfile对应的Nginx内部路径 + XSendfilePath string `json:"x_sendfile_path,omitempty"` // 如 /minio-files +} + +// Value 实现 driver.Valuer 接口, 用于写入数据库 +func (c PolicyConfig) Value() (driver.Value, error) { + return json.Marshal(c) +} + +// Scan 实现 sql.Scanner 接口, 用于从数据库读取 +func (c *PolicyConfig) Scan(value interface{}) error { + if value == nil { + return nil + } + bytes, ok := value.([]byte) + if !ok { + return fmt.Errorf("PolicyConfig.Scan: 无法将 %T 转换为 []byte", value) + } + return json.Unmarshal(bytes, c) +} diff --git a/server/model/user.go b/server/model/user.go new file mode 100644 index 0000000..fa20607 --- /dev/null +++ b/server/model/user.go @@ -0,0 +1,28 @@ +// Package model 定义数据模型 +package model + +import "time" + +// User 用户模型 +// 对应数据库表: fs_user +type User struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Username string `json:"username" gorm:"uniqueIndex;size:64;not null"` + Password string `json:"-" gorm:"size:256;not null"` // json:"-" 防止密码泄露 + Email string `json:"email" gorm:"size:128"` + Nickname string `json:"nickname" gorm:"size:64"` + Avatar string `json:"avatar" gorm:"size:512"` + Role string `json:"role" gorm:"size:32;default:user"` + Status int8 `json:"status" gorm:"default:1"` + DeptID uint64 `json:"dept_id" gorm:"index;default:0"` + StorageQuota int64 `json:"storage_quota" gorm:"default:10737418240"` + UsedStorage int64 `json:"used_storage" gorm:"default:0"` + MustChangePwd int8 `json:"must_change_pwd" gorm:"default:0"` // 1=首次登录必须改密码 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName 指定表名 +func (User) TableName() string { + return "fs_user" +} diff --git a/server/repository/department_repo.go b/server/repository/department_repo.go new file mode 100644 index 0000000..44fa162 --- /dev/null +++ b/server/repository/department_repo.go @@ -0,0 +1,72 @@ +package repository + +import ( + "seeyon-filesystem/config" + "seeyon-filesystem/model" +) + +// DepartmentRepository 部门数据访问 +type DepartmentRepository struct{} + +func NewDepartmentRepository() *DepartmentRepository { + return &DepartmentRepository{} +} + +// Create 创建部门 +func (r *DepartmentRepository) Create(dept *model.Department) error { + return config.DB.Create(dept).Error +} + +// FindByID 根据ID查找部门 +func (r *DepartmentRepository) FindByID(id uint64) (*model.Department, error) { + var dept model.Department + err := config.DB.First(&dept, id).Error + if err != nil { + return nil, err + } + return &dept, nil +} + +// List 查询所有部门 +func (r *DepartmentRepository) List() ([]model.Department, error) { + var depts []model.Department + err := config.DB.Order("sort_order ASC, id ASC").Find(&depts).Error + return depts, err +} + +// ListByParent 查询子部门 +func (r *DepartmentRepository) ListByParent(parentID uint64) ([]model.Department, error) { + var depts []model.Department + err := config.DB.Where("parent_id = ?", parentID).Order("sort_order ASC, id ASC").Find(&depts).Error + return depts, err +} + +// Update 更新部门 +func (r *DepartmentRepository) Update(dept *model.Department) error { + return config.DB.Save(dept).Error +} + +// Delete 删除部门 +func (r *DepartmentRepository) Delete(id uint64) error { + // 检查是否有子部门 + var count int64 + config.DB.Model(&model.Department{}).Where("parent_id = ?", id).Count(&count) + if count > 0 { + return nil // 有子部门不允许删除, 由service层处理错误 + } + return config.DB.Delete(&model.Department{}, id).Error +} + +// HasChildren 检查是否有子部门 +func (r *DepartmentRepository) HasChildren(id uint64) (bool, error) { + var count int64 + err := config.DB.Model(&model.Department{}).Where("parent_id = ?", id).Count(&count).Error + return count > 0, err +} + +// CountUsers 统计部门下的用户数 +func (r *DepartmentRepository) CountUsers(deptID uint64) (int64, error) { + var count int64 + err := config.DB.Model(&model.User{}).Where("dept_id = ?", deptID).Count(&count).Error + return count, err +} diff --git a/server/repository/file_repo.go b/server/repository/file_repo.go new file mode 100644 index 0000000..a205ec3 --- /dev/null +++ b/server/repository/file_repo.go @@ -0,0 +1,288 @@ +package repository + +import ( + "seeyon-filesystem/config" + "seeyon-filesystem/model" +) + +// FileRepository 文件数据访问 +type FileRepository struct{} + +// NewFileRepository 创建文件Repository实例 +func NewFileRepository() *FileRepository { + return &FileRepository{} +} + +// Create 创建文件记录 +func (r *FileRepository) Create(file *model.File) error { + return config.DB.Create(file).Error +} + +// FindByID 根据ID查找文件 +func (r *FileRepository) FindByID(id uint64) (*model.File, error) { + var file model.File + err := config.DB.First(&file, id).Error + if err != nil { + return nil, err + } + return &file, nil +} + +// FindByMD5 根据MD5查找文件(用于秒传) +func (r *FileRepository) FindByMD5(md5 string) (*model.File, error) { + var file model.File + err := config.DB.Where("md5 = ? AND status = 1", md5).First(&file).Error + if err != nil { + return nil, err + } + return &file, nil +} + +// ListByFolder 查询文件夹下的文件列表(基于权限) +// 使用 LEFT JOIN 一次性查出, 避免子查询 +func (r *FileRepository) ListByFolder(folderID uint64, userID uint64, roleIDs []uint64) ([]model.File, error) { + var files []model.File + + // 构建权限条件: 自己的 OR 直接授权 OR 角色授权 + 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) + } + + 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.folder_id = ? AND f.status = 1 AND ("+permCond+")", append([]interface{}{folderID}, args...)...). + Order("f.name ASC") + + err := query.Find(&files).Error + return files, err +} + +// ListByOwner 查询用户的所有文件(仅自己的) +func (r *FileRepository) ListByOwner(ownerID uint64) ([]model.File, error) { + var files []model.File + err := config.DB.Where("owner_id = ? AND status = 1", ownerID). + Order("created_at DESC").Find(&files).Error + return files, err +} + +// SearchByName 按文件名搜索(基于权限, 使用JOIN优化) +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) + } + + 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) + if len(keywords) > 1 { + conditions := make([]string, 0, len(keywords)) + args := make([]interface{}, 0, len(keywords)) + for _, kw := range keywords { + conditions = append(conditions, "name LIKE ?") + args = append(args, "%"+kw+"%") + } + query = query.Where("("+joinStrings(conditions, " OR ")+")", args...) + } else { + query = query.Where("name LIKE ?", "%"+keywords[0]+"%") + } + } + + err := query.Order("created_at DESC").Limit(200).Find(&files).Error + return files, err +} + +// SearchByType 按文件类型搜索 +func (r *FileRepository) SearchByType(ownerID uint64, extensions []string) ([]model.File, error) { + var files []model.File + err := config.DB.Where("owner_id = ? AND status = 1 AND extension IN ?", ownerID, extensions). + Order("created_at DESC").Limit(200).Find(&files).Error + return files, err +} + +// SearchByPolicy 按存储策略搜索(管理员用) +func (r *FileRepository) SearchByPolicy(policyID uint64) ([]model.File, error) { + var files []model.File + err := config.DB.Where("storage_policy_id = ? AND status = 1", policyID). + Order("created_at DESC").Limit(500).Find(&files).Error + return files, err +} + +func splitKeywords(s string) []string { + var result []string + for _, kw := range splitByChar(s, '|') { + kw = trimSpace(kw) + if kw != "" { + result = append(result, kw) + } + } + if len(result) == 0 { + result = []string{s} + } + return result +} + +func splitByChar(s string, sep byte) []string { + var result []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == sep { + result = append(result, s[start:i]) + start = i + 1 + } + } + result = append(result, s[start:]) + return result +} + +func trimSpace(s string) string { + start, end := 0, len(s) + for start < end && s[start] == ' ' { + start++ + } + for end > start && s[end-1] == ' ' { + end-- + } + return s[start:end] +} + +func joinStrings(strs []string, sep string) string { + if len(strs) == 0 { + return "" + } + result := strs[0] + for i := 1; i < len(strs); i++ { + result += sep + strs[i] + } + return result +} + +// ListTrashed 查询回收站文件 +func (r *FileRepository) ListTrashed(ownerID uint64) ([]model.File, error) { + var files []model.File + err := config.DB.Where("owner_id = ? AND status = 0", ownerID). + Order("updated_at DESC").Find(&files).Error + return files, err +} + +// Update 更新文件信息 +func (r *FileRepository) Update(file *model.File) error { + return config.DB.Save(file).Error +} + +// SoftDelete 软删除(移入回收站) +func (r *FileRepository) SoftDelete(id uint64) error { + return config.DB.Model(&model.File{}).Where("id = ?", id).Update("status", 0).Error +} + +// Restore 从回收站恢复 +func (r *FileRepository) Restore(id uint64) error { + return config.DB.Model(&model.File{}).Where("id = ?", id).Update("status", 1).Error +} + +// HardDelete 永久删除 +func (r *FileRepository) HardDelete(id uint64) error { + return config.DB.Unscoped().Delete(&model.File{}, id).Error +} + +// IncrementDownloadCount 增加下载次数 +func (r *FileRepository) IncrementDownloadCount(id uint64) error { + return config.DB.Model(&model.File{}).Where("id = ?", id). + Update("download_count", config.DB.Raw("download_count + 1")).Error +} + +// CountByOwner 统计用户文件数 +func (r *FileRepository) CountByOwner(ownerID uint64) (int64, error) { + var count int64 + err := config.DB.Model(&model.File{}).Where("owner_id = ? AND status = 1", ownerID).Count(&count).Error + return count, err +} + +// SumSizeByOwner 统计用户文件总大小 +func (r *FileRepository) SumSizeByOwner(ownerID uint64) (int64, error) { + var total int64 + err := config.DB.Model(&model.File{}).Where("owner_id = ? AND status = 1", ownerID). + Select("COALESCE(SUM(size), 0)").Scan(&total).Error + return total, err +} + +// PolicyStats 存储策略统计 +type PolicyStats struct { + PolicyID uint64 `json:"policy_id"` + PolicyName string `json:"policy_name"` + PolicyType string `json:"policy_type"` + FileCount int64 `json:"file_count"` + TotalSize int64 `json:"total_size"` + UserCount int64 `json:"user_count"` +} + +// GetPolicyStats 获取所有存储策略的使用统计 +func (r *FileRepository) GetPolicyStats() ([]PolicyStats, error) { + var stats []PolicyStats + err := config.DB.Raw(` + SELECT + f.storage_policy_id as policy_id, + COALESCE(sp.name, '未知') as policy_name, + COALESCE(sp.policy_type, '') as policy_type, + COUNT(*) as file_count, + COALESCE(SUM(f.size), 0) as total_size, + COUNT(DISTINCT f.owner_id) as user_count + FROM fs_file f + LEFT JOIN fs_storage_policy sp ON sp.id = f.storage_policy_id + WHERE f.status = 1 + GROUP BY f.storage_policy_id, sp.name, sp.policy_type + ORDER BY total_size DESC + `).Scan(&stats).Error + return stats, err +} + +// GetGlobalStats 获取全局统计 +func (r *FileRepository) GetGlobalStats() (map[string]interface{}, error) { + var totalFiles int64 + var totalSize int64 + var totalUsers int64 + + config.DB.Model(&model.File{}).Where("status = 1").Count(&totalFiles) + config.DB.Model(&model.File{}).Where("status = 1").Select("COALESCE(SUM(size), 0)").Scan(&totalSize) + config.DB.Model(&model.File{}).Where("status = 1").Distinct("owner_id").Count(&totalUsers) + + var totalFolders int64 + config.DB.Model(&model.Folder{}).Where("status = 1").Count(&totalFolders) + + return map[string]interface{}{ + "total_files": totalFiles, + "total_size": totalSize, + "total_folders": totalFolders, + "total_users": totalUsers, + }, nil +} diff --git a/server/repository/folder_repo.go b/server/repository/folder_repo.go new file mode 100644 index 0000000..4abcc29 --- /dev/null +++ b/server/repository/folder_repo.go @@ -0,0 +1,94 @@ +package repository + +import ( + "seeyon-filesystem/config" + "seeyon-filesystem/model" +) + +// FolderRepository 文件夹数据访问 +type FolderRepository struct{} + +// NewFolderRepository 创建文件夹Repository实例 +func NewFolderRepository() *FolderRepository { + return &FolderRepository{} +} + +// Create 创建文件夹 +func (r *FolderRepository) Create(folder *model.Folder) error { + return config.DB.Create(folder).Error +} + +// FindByID 根据ID查找文件夹 +func (r *FolderRepository) FindByID(id uint64) (*model.Folder, error) { + var folder model.Folder + err := config.DB.First(&folder, id).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 + err := config.DB.Where("parent_id = ? AND owner_id = ? AND status = 1", parentID, ownerID). + Order("name ASC").Find(&folders).Error + return folders, err +} + +// FindByPath 根据路径查找文件夹 +func (r *FolderRepository) FindByPath(path string, ownerID uint64) (*model.Folder, error) { + var folder model.Folder + err := config.DB.Where("path = ? AND owner_id = ? AND status = 1", path, ownerID).First(&folder).Error + if err != nil { + return nil, err + } + return &folder, nil +} + +// FindByNameAndParent 根据名称和父文件夹查找(用于判断重名) +func (r *FolderRepository) FindByNameAndParent(name string, parentID uint64, ownerID uint64) (*model.Folder, error) { + var folder model.Folder + err := config.DB.Where("name = ? AND parent_id = ? AND owner_id = ? AND status = 1", + name, parentID, ownerID).First(&folder).Error + if err != nil { + return nil, err + } + return &folder, nil +} + +// ListTrashed 查询回收站文件夹 +func (r *FolderRepository) ListTrashed(ownerID uint64) ([]model.Folder, error) { + var folders []model.Folder + err := config.DB.Where("owner_id = ? AND status = 0", ownerID). + Order("updated_at DESC").Find(&folders).Error + return folders, err +} + +// Update 更新文件夹 +func (r *FolderRepository) Update(folder *model.Folder) error { + return config.DB.Save(folder).Error +} + +// SoftDelete 软删除 +func (r *FolderRepository) SoftDelete(id uint64) error { + return config.DB.Model(&model.Folder{}).Where("id = ?", id).Update("status", 0).Error +} + +// Restore 从回收站恢复 +func (r *FolderRepository) Restore(id uint64) error { + return config.DB.Model(&model.Folder{}).Where("id = ?", id).Update("status", 1).Error +} + +// HardDelete 永久删除 +func (r *FolderRepository) HardDelete(id uint64) error { + return config.DB.Unscoped().Delete(&model.Folder{}, id).Error +} + +// GetChildrenPath 获取所有子孙文件夹的路径前缀(用于级联操作) +func (r *FolderRepository) GetChildrenByPath(pathPrefix string, ownerID uint64) ([]model.Folder, error) { + var folders []model.Folder + err := config.DB.Where("path LIKE ? AND owner_id = ?", + pathPrefix+"%", ownerID).Find(&folders).Error + return folders, err +} diff --git a/server/repository/permission_repo.go b/server/repository/permission_repo.go new file mode 100644 index 0000000..7d8156f --- /dev/null +++ b/server/repository/permission_repo.go @@ -0,0 +1,82 @@ +package repository + +import ( + "seeyon-filesystem/config" + "seeyon-filesystem/model" + + "gorm.io/gorm" +) + +// PermissionRepository 权限数据访问 +type PermissionRepository struct{} + +func NewPermissionRepository() *PermissionRepository { + return &PermissionRepository{} +} + +// FindByID 根据ID查找权限 +func (r *PermissionRepository) FindByID(id uint64) (*model.Permission, error) { + var perm model.Permission + err := config.DB.First(&perm, id).Error + if err != nil { + return nil, err + } + return &perm, nil +} + +// FindByCode 根据权限标识查找 +func (r *PermissionRepository) FindByCode(code string) (*model.Permission, error) { + var perm model.Permission + err := config.DB.Where("code = ?", code).First(&perm).Error + if err != nil { + return nil, err + } + return &perm, nil +} + +// List 查询所有权限 +func (r *PermissionRepository) List() ([]model.Permission, error) { + var perms []model.Permission + err := config.DB.Order("resource ASC, action ASC").Find(&perms).Error + return perms, err +} + +// ListByResource 按资源类型查询权限 +func (r *PermissionRepository) ListByResource(resource string) ([]model.Permission, error) { + var perms []model.Permission + err := config.DB.Where("resource = ?", resource).Order("action ASC").Find(&perms).Error + return perms, err +} + +// GetUserPermissionCodes 获取用户的所有权限标识(通过角色) +func (r *PermissionRepository) GetUserPermissionCodes(userID uint64) ([]string, error) { + var codes []string + err := config.DB.Table("fs_permission p"). + Select("DISTINCT p.code"). + Joins("JOIN fs_role_permission rp ON rp.permission_id = p.id"). + Joins("JOIN fs_user_role ur ON ur.role_id = rp.role_id"). + Where("ur.user_id = ?", userID). + Pluck("p.code", &codes).Error + return codes, err +} + +// Create 创建权限 +func (r *PermissionRepository) Create(perm *model.Permission) error { + return config.DB.Create(perm).Error +} + +// Update 更新权限 +func (r *PermissionRepository) Update(perm *model.Permission) error { + return config.DB.Save(perm).Error +} + +// Delete 删除权限 +func (r *PermissionRepository) Delete(id uint64) error { + return config.DB.Transaction(func(tx *gorm.DB) error { + // 删除角色-权限关联 + if err := tx.Where("permission_id = ?", id).Delete(&model.RolePermission{}).Error; err != nil { + return err + } + return tx.Delete(&model.Permission{}, id).Error + }) +} diff --git a/server/repository/role_repo.go b/server/repository/role_repo.go new file mode 100644 index 0000000..365a26d --- /dev/null +++ b/server/repository/role_repo.go @@ -0,0 +1,86 @@ +package repository + +import ( + "seeyon-filesystem/config" + "seeyon-filesystem/model" + + "gorm.io/gorm" +) + +// RoleRepository 角色数据访问 +type RoleRepository struct{} + +func NewRoleRepository() *RoleRepository { + return &RoleRepository{} +} + +// Create 创建角色 +func (r *RoleRepository) Create(role *model.Role) error { + return config.DB.Create(role).Error +} + +// FindByID 根据ID查找角色(含权限) +func (r *RoleRepository) FindByID(id uint64) (*model.Role, error) { + var role model.Role + err := config.DB.Preload("Permissions").First(&role, id).Error + if err != nil { + return nil, err + } + return &role, nil +} + +// FindByName 根据标识查找角色 +func (r *RoleRepository) FindByName(name string) (*model.Role, error) { + var role model.Role + err := config.DB.Where("name = ?", name).First(&role).Error + if err != nil { + return nil, err + } + return &role, nil +} + +// List 查询所有角色 +func (r *RoleRepository) List() ([]model.Role, error) { + var roles []model.Role + err := config.DB.Preload("Permissions").Order("id ASC").Find(&roles).Error + return roles, err +} + +// Update 更新角色 +func (r *RoleRepository) Update(role *model.Role) error { + return config.DB.Save(role).Error +} + +// Delete 删除角色(仅非系统角色) +func (r *RoleRepository) Delete(id uint64) error { + return config.DB.Transaction(func(tx *gorm.DB) error { + // 删除角色-权限关联 + if err := tx.Where("role_id = ?", id).Delete(&model.RolePermission{}).Error; err != nil { + return err + } + // 删除用户-角色关联 + if err := tx.Where("role_id = ?", id).Delete(&model.UserRole{}).Error; err != nil { + return err + } + // 删除角色 + return tx.Delete(&model.Role{}, id).Error + }) +} + +// SetPermissions 设置角色权限(全量替换) +func (r *RoleRepository) SetPermissions(roleID uint64, permissionIDs []uint64) error { + return config.DB.Transaction(func(tx *gorm.DB) error { + // 先删除旧关联 + if err := tx.Where("role_id = ?", roleID).Delete(&model.RolePermission{}).Error; err != nil { + return err + } + // 插入新关联 + for _, pid := range permissionIDs { + rp := model.RolePermission{RoleID: roleID, PermissionID: pid} + if err := tx.Create(&rp).Error; err != nil { + return err + } + } + return nil + }) +} diff --git a/server/repository/share_repo.go b/server/repository/share_repo.go new file mode 100644 index 0000000..37aae30 --- /dev/null +++ b/server/repository/share_repo.go @@ -0,0 +1,63 @@ +package repository + +import ( + "seeyon-filesystem/config" + "seeyon-filesystem/model" +) + +// ShareRepository 分享数据访问 +type ShareRepository struct{} + +// NewShareRepository 创建分享Repository实例 +func NewShareRepository() *ShareRepository { + return &ShareRepository{} +} + +// Create 创建分享记录 +func (r *ShareRepository) Create(share *model.Share) error { + return config.DB.Create(share).Error +} + +// FindByShareCode 根据分享码查找 +func (r *ShareRepository) FindByShareCode(code string) (*model.Share, error) { + var share model.Share + err := config.DB.Where("share_code = ? AND status = 1", code).First(&share).Error + if err != nil { + return nil, err + } + return &share, nil +} + +// FindByID 根据ID查找 +func (r *ShareRepository) FindByID(id uint64) (*model.Share, error) { + var share model.Share + err := config.DB.First(&share, id).Error + if err != nil { + return nil, err + } + return &share, nil +} + +// ListByOwner 查询用户的分享列表 +func (r *ShareRepository) ListByOwner(ownerID uint64) ([]model.Share, error) { + var shares []model.Share + err := config.DB.Where("owner_id = ? AND status = 1", ownerID). + Order("created_at DESC").Find(&shares).Error + return shares, err +} + +// IncrementDownloadCount 增加下载次数 +func (r *ShareRepository) IncrementDownloadCount(id uint64) error { + return config.DB.Model(&model.Share{}).Where("id = ?", id). + Update("download_count", config.DB.Raw("download_count + 1")).Error +} + +// Cancel 取消分享 +func (r *ShareRepository) Cancel(id uint64) error { + return config.DB.Model(&model.Share{}).Where("id = ?", id).Update("status", 0).Error +} + +// Delete 永久删除 +func (r *ShareRepository) Delete(id uint64) error { + return config.DB.Unscoped().Delete(&model.Share{}, id).Error +} diff --git a/server/repository/sso_repo.go b/server/repository/sso_repo.go new file mode 100644 index 0000000..c504c3b --- /dev/null +++ b/server/repository/sso_repo.go @@ -0,0 +1,94 @@ +package repository + +import ( + "seeyon-filesystem/config" + "seeyon-filesystem/model" + + "gorm.io/gorm" +) + +// SSORepository SSO数据访问 +type SSORepository struct{} + +func NewSSORepository() *SSORepository { + return &SSORepository{} +} + +// ========== SSO 提供商 ========== + +// CreateProvider 创建SSO提供商 +func (r *SSORepository) CreateProvider(provider *model.SSOProvider) error { + return config.DB.Create(provider).Error +} + +// FindProviderByID 根据ID查找提供商 +func (r *SSORepository) FindProviderByID(id uint64) (*model.SSOProvider, error) { + var provider model.SSOProvider + err := config.DB.First(&provider, id).Error + if err != nil { + return nil, err + } + return &provider, nil +} + +// ListProviders 查询所有启用的提供商 +func (r *SSORepository) ListProviders() ([]model.SSOProvider, error) { + var providers []model.SSOProvider + err := config.DB.Where("status = 1").Order("sort_order ASC, id ASC").Find(&providers).Error + return providers, err +} + +// ListAllProviders 查询所有提供商(含禁用) +func (r *SSORepository) ListAllProviders() ([]model.SSOProvider, error) { + var providers []model.SSOProvider + err := config.DB.Order("sort_order ASC, id ASC").Find(&providers).Error + return providers, err +} + +// UpdateProvider 更新提供商 +func (r *SSORepository) UpdateProvider(provider *model.SSOProvider) error { + return config.DB.Save(provider).Error +} + +// DeleteProvider 删除提供商 +func (r *SSORepository) DeleteProvider(id uint64) error { + return config.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("provider_id = ?", id).Delete(&model.SSOSession{}).Error; err != nil { + return err + } + return tx.Delete(&model.SSOProvider{}, id).Error + }) +} + +// ========== SSO 会话 ========== + +// CreateSession 创建SSO会话 +func (r *SSORepository) CreateSession(session *model.SSOSession) error { + return config.DB.Create(session).Error +} + +// FindSessionByExternalID 根据外部用户ID查找会话 +func (r *SSORepository) FindSessionByExternalID(providerID uint64, externalID string) (*model.SSOSession, error) { + var session model.SSOSession + err := config.DB.Where("provider_id = ? AND external_id = ?", providerID, externalID). + First(&session).Error + if err != nil { + return nil, err + } + return &session, nil +} + +// FindSessionByUserID 根据本地用户ID查找SSO会话 +func (r *SSORepository) FindSessionByUserID(userID uint64) (*model.SSOSession, error) { + var session model.SSOSession + err := config.DB.Where("user_id = ?", userID).Order("last_login_at DESC").First(&session).Error + if err != nil { + return nil, err + } + return &session, nil +} + +// UpdateSession 更新会话 +func (r *SSORepository) UpdateSession(session *model.SSOSession) error { + return config.DB.Save(session).Error +} diff --git a/server/repository/storage_policy_repo.go b/server/repository/storage_policy_repo.go new file mode 100644 index 0000000..6002608 --- /dev/null +++ b/server/repository/storage_policy_repo.go @@ -0,0 +1,45 @@ +package repository + +import ( + "seeyon-filesystem/config" + "seeyon-filesystem/model" +) + +// StoragePolicyRepository 存储策略数据访问 +type StoragePolicyRepository struct{} + +func NewStoragePolicyRepository() *StoragePolicyRepository { + return &StoragePolicyRepository{} +} + +// Create 创建存储策略 +func (r *StoragePolicyRepository) Create(policy *model.StoragePolicy) error { + return config.DB.Create(policy).Error +} + +// FindByID 根据ID查找 +func (r *StoragePolicyRepository) FindByID(id uint64) (*model.StoragePolicy, error) { + var policy model.StoragePolicy + err := config.DB.First(&policy, id).Error + if err != nil { + return nil, err + } + return &policy, nil +} + +// List 查询所有存储策略 +func (r *StoragePolicyRepository) List() ([]model.StoragePolicy, error) { + var policies []model.StoragePolicy + err := config.DB.Order("id ASC").Find(&policies).Error + return policies, err +} + +// Update 更新存储策略 +func (r *StoragePolicyRepository) Update(policy *model.StoragePolicy) error { + return config.DB.Save(policy).Error +} + +// Delete 删除存储策略 +func (r *StoragePolicyRepository) Delete(id uint64) error { + return config.DB.Delete(&model.StoragePolicy{}, id).Error +} diff --git a/server/repository/user_repo.go b/server/repository/user_repo.go new file mode 100644 index 0000000..7bac9ca --- /dev/null +++ b/server/repository/user_repo.go @@ -0,0 +1,68 @@ +// Package repository 提供数据访问层 +// 封装GORM的数据库操作, 供Service层调用 +package repository + +import ( + "seeyon-filesystem/config" + "seeyon-filesystem/model" +) + +// UserRepository 用户数据访问 +type UserRepository struct{} + +// NewUserRepository 创建用户Repository实例 +func NewUserRepository() *UserRepository { + return &UserRepository{} +} + +// Create 创建用户 +func (r *UserRepository) Create(user *model.User) error { + return config.DB.Create(user).Error +} + +// FindByID 根据ID查找用户 +func (r *UserRepository) FindByID(id uint64) (*model.User, error) { + var user model.User + err := config.DB.First(&user, id).Error + if err != nil { + return nil, err + } + return &user, nil +} + +// FindByUsername 根据用户名查找用户 +func (r *UserRepository) FindByUsername(username string) (*model.User, error) { + var user model.User + err := config.DB.Where("username = ?", username).First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + +// Update 更新用户信息 +func (r *UserRepository) Update(user *model.User) error { + return config.DB.Save(user).Error +} + +// UpdateStorageUsed 更新用户已用存储空间 +func (r *UserRepository) UpdateStorageUsed(userID uint64, delta int64) error { + return config.DB.Model(&model.User{}). + Where("id = ?", userID). + Update("used_storage", config.DB.Raw("used_storage + ?", delta)).Error +} + +// List 分页查询用户列表 +func (r *UserRepository) List(page, size int) ([]model.User, int64, error) { + var users []model.User + var total int64 + + config.DB.Model(&model.User{}).Count(&total) + err := config.DB.Offset((page - 1) * size).Limit(size).Order("id DESC").Find(&users).Error + return users, total, err +} + +// Delete 删除用户(软删除) +func (r *UserRepository) Delete(id uint64) error { + return config.DB.Delete(&model.User{}, id).Error +} diff --git a/server/repository/user_role_repo.go b/server/repository/user_role_repo.go new file mode 100644 index 0000000..732eb9a --- /dev/null +++ b/server/repository/user_role_repo.go @@ -0,0 +1,53 @@ +package repository + +import ( + "seeyon-filesystem/config" + "seeyon-filesystem/model" + + "gorm.io/gorm" +) + +// UserRoleRepository 用户-角色关联数据访问 +type UserRoleRepository struct{} + +func NewUserRoleRepository() *UserRoleRepository { + return &UserRoleRepository{} +} + +// SetUserRoles 设置用户角色(全量替换) +func (r *UserRoleRepository) SetUserRoles(userID uint64, roleIDs []uint64) error { + return config.DB.Transaction(func(tx *gorm.DB) error { + // 先删除旧关联 + if err := tx.Where("user_id = ?", userID).Delete(&model.UserRole{}).Error; err != nil { + return err + } + // 插入新关联 + for _, rid := range roleIDs { + ur := model.UserRole{UserID: userID, RoleID: rid} + if err := tx.Create(&ur).Error; err != nil { + return err + } + } + return nil + }) +} + +// GetUserRoles 获取用户的角色列表 +func (r *UserRoleRepository) GetUserRoles(userID uint64) ([]model.Role, error) { + var roles []model.Role + err := config.DB.Table("fs_role ro"). + Joins("JOIN fs_user_role ur ON ur.role_id = ro.id"). + Where("ur.user_id = ? AND ro.status = 1", userID). + Find(&roles).Error + return roles, err +} + +// GetRoleUsers 获取角色下的用户列表 +func (r *UserRoleRepository) GetRoleUsers(roleID uint64) ([]model.User, error) { + var users []model.User + err := config.DB.Table("fs_user u"). + Joins("JOIN fs_user_role ur ON ur.user_id = u.id"). + Where("ur.role_id = ?", roleID). + Find(&users).Error + return users, err +} diff --git a/server/service/admin_service.go b/server/service/admin_service.go new file mode 100644 index 0000000..dbe2f49 --- /dev/null +++ b/server/service/admin_service.go @@ -0,0 +1,363 @@ +package service + +import ( + "errors" + "fmt" + + "seeyon-filesystem/dto" + "seeyon-filesystem/model" + "seeyon-filesystem/repository" + "seeyon-filesystem/utils" +) + +// AdminService 管理后台服务 +// 包含用户管理、存储策略管理、组织架构管理 +type AdminService struct { + userRepo *repository.UserRepository + roleRepo *repository.RoleRepository + userRoleRepo *repository.UserRoleRepository + deptRepo *repository.DepartmentRepository + policyRepo *repository.StoragePolicyRepository +} + +func NewAdminService() *AdminService { + return &AdminService{ + userRepo: repository.NewUserRepository(), + roleRepo: repository.NewRoleRepository(), + userRoleRepo: repository.NewUserRoleRepository(), + deptRepo: repository.NewDepartmentRepository(), + policyRepo: repository.NewStoragePolicyRepository(), + } +} + +// ========== 用户管理 ========== + +// DefaultPassword 新用户默认密码 +const DefaultPassword = "123456" + +// CreateUser 创建用户 +func (s *AdminService) CreateUser(req *dto.UserCreateRequest) error { + existing, _ := s.userRepo.FindByUsername(req.Username) + if existing != nil { + return errors.New("用户名已存在") + } + + // 使用指定密码, 或默认密码 + password := req.Password + if password == "" { + password = DefaultPassword + } + + hashed, err := utils.HashPassword(password) + if err != nil { + return errors.New("密码加密失败") + } + + user := &model.User{ + Username: req.Username, + Password: hashed, + Email: req.Email, + Nickname: req.Nickname, + Role: "user", + Status: 1, + StorageQuota: req.StorageQuota, + MustChangePwd: 1, // 新用户首次登录必须改密码 + } + if user.StorageQuota == 0 { + user.StorageQuota = 10737418240 + } + if user.Nickname == "" { + user.Nickname = req.Username + } + + if err := s.userRepo.Create(user); err != nil { + return fmt.Errorf("创建用户失败: %w", err) + } + + // 分配角色 + if len(req.RoleIDs) > 0 { + s.userRoleRepo.SetUserRoles(user.ID, req.RoleIDs) + } + + return nil +} + +// ListUsers 分页查询用户列表 +func (s *AdminService) ListUsers(page, size int) ([]dto.UserVO, int64, error) { + users, total, err := s.userRepo.List(page, size) + if err != nil { + return nil, 0, err + } + + vos := make([]dto.UserVO, 0, len(users)) + for _, u := range users { + vo := dto.UserVO{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + Nickname: u.Nickname, + Avatar: u.Avatar, + Status: u.Status, + StorageQuota: u.StorageQuota, + UsedStorage: u.UsedStorage, + CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"), + } + // 获取用户角色 + roles, _ := s.userRoleRepo.GetUserRoles(u.ID) + for _, r := range roles { + vo.Roles = append(vo.Roles, dto.RoleVO{ID: r.ID, Name: r.Name, DisplayName: r.DisplayName}) + } + vos = append(vos, vo) + } + + return vos, total, nil +} + +// UpdateUser 更新用户 +func (s *AdminService) UpdateUser(userID uint64, req *dto.UserUpdateRequest) error { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return errors.New("用户不存在") + } + + if req.Email != "" { + user.Email = req.Email + } + if req.Nickname != "" { + user.Nickname = req.Nickname + } + if req.Status != nil { + user.Status = *req.Status + } + if req.StorageQuota != nil { + user.StorageQuota = *req.StorageQuota + } + + if err := s.userRepo.Update(user); err != nil { + return fmt.Errorf("更新用户失败: %w", err) + } + + // 更新角色 + if req.RoleIDs != nil { + s.userRoleRepo.SetUserRoles(userID, req.RoleIDs) + } + + return nil +} + +// DeleteUser 删除用户 +func (s *AdminService) DeleteUser(userID uint64) error { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return errors.New("用户不存在") + } + if user.Username == "admin" { + return errors.New("不能删除管理员账号") + } + return s.userRepo.Delete(userID) +} + +// ResetPassword 重置用户密码 +func (s *AdminService) ResetPassword(userID uint64, newPassword string) error { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return errors.New("用户不存在") + } + + hashed, err := utils.HashPassword(newPassword) + if err != nil { + return errors.New("密码加密失败") + } + + user.Password = hashed + return s.userRepo.Update(user) +} + +// ========== 存储策略管理 ========== + +// CreateStoragePolicy 创建存储策略 +func (s *AdminService) CreateStoragePolicy(req *dto.StoragePolicyCreateRequest) error { + policy := &model.StoragePolicy{ + Name: req.Name, + Type: req.Type, + Status: 1, + Config: model.PolicyConfig{ + BasePath: req.Config.BasePath, + Endpoint: req.Config.Endpoint, + Bucket: req.Config.Bucket, + AccessKey: req.Config.AccessKey, + SecretKey: req.Config.SecretKey, + Region: req.Config.Region, + UseSSL: req.Config.UseSSL, + }, + } + return s.policyRepo.Create(policy) +} + +// ListStoragePolicies 查询所有存储策略 +func (s *AdminService) ListStoragePolicies() ([]dto.StoragePolicyVO, error) { + policies, err := s.policyRepo.List() + if err != nil { + return nil, err + } + + vos := make([]dto.StoragePolicyVO, 0, len(policies)) + for _, p := range policies { + vos = append(vos, dto.StoragePolicyVO{ + ID: p.ID, + Name: p.Name, + Type: p.Type, + Status: p.Status, + Config: dto.PolicyConfigDTO{ + BasePath: p.Config.BasePath, + Endpoint: p.Config.Endpoint, + Bucket: p.Config.Bucket, + AccessKey: p.Config.AccessKey, + SecretKey: "***", // 不返回密钥 + Region: p.Config.Region, + UseSSL: p.Config.UseSSL, + }, + CreatedAt: p.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + return vos, nil +} + +// UpdateStoragePolicy 更新存储策略 +func (s *AdminService) UpdateStoragePolicy(id uint64, req *dto.StoragePolicyUpdateRequest) error { + policy, err := s.policyRepo.FindByID(id) + if err != nil { + return errors.New("存储策略不存在") + } + + if req.Name != "" { + policy.Name = req.Name + } + if req.Status != nil { + policy.Status = *req.Status + } + if req.Config != nil { + policy.Config = model.PolicyConfig{ + BasePath: req.Config.BasePath, + Endpoint: req.Config.Endpoint, + Bucket: req.Config.Bucket, + AccessKey: req.Config.AccessKey, + SecretKey: req.Config.SecretKey, + Region: req.Config.Region, + UseSSL: req.Config.UseSSL, + } + } + + return s.policyRepo.Update(policy) +} + +// DeleteStoragePolicy 删除存储策略 +func (s *AdminService) DeleteStoragePolicy(id uint64) error { + return s.policyRepo.Delete(id) +} + +// ========== 组织架构管理 ========== + +// CreateDepartment 创建部门 +func (s *AdminService) CreateDepartment(req *dto.DeptCreateRequest) error { + parentPath := "/" + if req.ParentID > 0 { + parent, err := s.deptRepo.FindByID(req.ParentID) + if err != nil { + return errors.New("上级部门不存在") + } + parentPath = parent.Path + } + + dept := &model.Department{ + Name: req.Name, + Code: req.Code, + ParentID: req.ParentID, + LeaderID: req.LeaderID, + SortOrder: req.SortOrder, + Path: parentPath, + Status: 1, + } + + if err := s.deptRepo.Create(dept); err != nil { + return fmt.Errorf("创建部门失败: %w", err) + } + + // 更新物化路径 + dept.Path = fmt.Sprintf("%s%d/", parentPath, dept.ID) + return s.deptRepo.Update(dept) +} + +// ListDepartments 查询所有部门(树形) +func (s *AdminService) ListDepartments() ([]dto.DeptVO, error) { + depts, err := s.deptRepo.List() + if err != nil { + return nil, err + } + + // 构建树形结构 + deptMap := make(map[uint64]*dto.DeptVO) + var roots []dto.DeptVO + + for _, d := range depts { + vo := dto.DeptVO{ + ID: d.ID, + Name: d.Name, + Code: d.Code, + ParentID: d.ParentID, + Path: d.Path, + LeaderID: d.LeaderID, + SortOrder: d.SortOrder, + Status: d.Status, + } + deptMap[d.ID] = &vo + } + + for _, d := range depts { + vo := deptMap[d.ID] + if d.ParentID == 0 { + roots = append(roots, *vo) + } else if parent, ok := deptMap[d.ParentID]; ok { + parent.Children = append(parent.Children, *vo) + } + } + + return roots, nil +} + +// UpdateDepartment 更新部门 +func (s *AdminService) UpdateDepartment(id uint64, req *dto.DeptUpdateRequest) error { + dept, err := s.deptRepo.FindByID(id) + if err != nil { + return errors.New("部门不存在") + } + + if req.Name != "" { + dept.Name = req.Name + } + if req.Code != "" { + dept.Code = req.Code + } + dept.LeaderID = req.LeaderID + dept.SortOrder = req.SortOrder + if req.Status != nil { + dept.Status = *req.Status + } + + return s.deptRepo.Update(dept) +} + +// DeleteDepartment 删除部门 +func (s *AdminService) DeleteDepartment(id uint64) error { + hasChildren, _ := s.deptRepo.HasChildren(id) + if hasChildren { + return errors.New("该部门下有子部门, 请先删除子部门") + } + + userCount, _ := s.deptRepo.CountUsers(id) + if userCount > 0 { + return errors.New("该部门下有用户, 请先移除用户") + } + + return s.deptRepo.Delete(id) +} diff --git a/server/service/auth_service.go b/server/service/auth_service.go new file mode 100644 index 0000000..f4cf6a0 --- /dev/null +++ b/server/service/auth_service.go @@ -0,0 +1,121 @@ +// Package service 提供业务逻辑层 +package service + +import ( + "errors" + + "seeyon-filesystem/config" + "seeyon-filesystem/dto" + "seeyon-filesystem/model" + "seeyon-filesystem/repository" + "seeyon-filesystem/utils" + + "gorm.io/gorm" +) + +// AuthService 认证服务 +type AuthService struct { + userRepo *repository.UserRepository +} + +// NewAuthService 创建认证服务实例 +func NewAuthService() *AuthService { + return &AuthService{ + userRepo: repository.NewUserRepository(), + } +} + +// Login 用户登录 +// 验证用户名密码, 生成JWT Token +func (s *AuthService) Login(req *dto.LoginRequest) (*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("账号已被禁用") + } + + // 验证密码 + if !utils.CheckPassword(req.Password, user.Password) { + return nil, errors.New("用户名或密码错误") + } + + // 生成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) + if existing != nil { + return errors.New("用户名已存在") + } + + hashedPassword, err := utils.HashPassword(req.Password) + if err != nil { + return errors.New("密码加密失败") + } + + user := &model.User{ + Username: req.Username, + Password: hashedPassword, + Email: req.Email, + Nickname: req.Nickname, + Role: "user", + Status: 1, + } + + return s.userRepo.Create(user) +} + +// ChangePassword 修改密码 +func (s *AuthService) ChangePassword(userID uint64, req *dto.ChangePasswordRequest) error { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return errors.New("用户不存在") + } + + // 验证旧密码 + if !utils.CheckPassword(req.OldPassword, user.Password) { + return errors.New("当前密码错误") + } + + // 新密码不能和旧密码相同 + if req.OldPassword == req.NewPassword { + return errors.New("新密码不能与当前密码相同") + } + + // 加密新密码 + hashedPassword, err := utils.HashPassword(req.NewPassword) + if err != nil { + return errors.New("密码加密失败") + } + + user.Password = hashedPassword + user.MustChangePwd = 0 // 清除强制改密码标记 + return s.userRepo.Update(user) +} diff --git a/server/service/file_service.go b/server/service/file_service.go new file mode 100644 index 0000000..e337bae --- /dev/null +++ b/server/service/file_service.go @@ -0,0 +1,479 @@ +package service + +import ( + "errors" + "fmt" + "io" + "mime/multipart" + "os" + "path/filepath" + "time" + + "seeyon-filesystem/config" + "seeyon-filesystem/dto" + "seeyon-filesystem/model" + "seeyon-filesystem/repository" + "seeyon-filesystem/storage" + "seeyon-filesystem/utils" +) + +// FileService 文件业务服务 +type FileService struct { + fileRepo *repository.FileRepository + folderRepo *repository.FolderRepository + userRepo *repository.UserRepository + assignService *StorageAssignService +} + +// NewFileService 创建文件服务实例 +func NewFileService() *FileService { + return &FileService{ + fileRepo: repository.NewFileRepository(), + folderRepo: repository.NewFolderRepository(), + userRepo: repository.NewUserRepository(), + assignService: NewStorageAssignService(), + } +} + +// Upload 上传文件 +// 流程: 计算MD5 -> 检查秒传 -> 存储文件 -> 保存元数据 +// storagePolicyID: 存储策略ID, 0=使用默认策略 +func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64, ownerID uint64, storagePolicyID uint64) (*dto.FileVO, error) { + // 验证文件夹 + if folderID > 0 { + folder, err := s.folderRepo.FindByID(folderID) + if err != nil { + return nil, errors.New("目标文件夹不存在") + } + if folder.OwnerID != ownerID { + return nil, errors.New("无权上传到此文件夹") + } + } + + // 确定存储策略: 优先使用指定策略, 否则根据用户分配自动确定 + var engine storage.Engine + var policyID uint64 + if storagePolicyID > 0 { + policyID = storagePolicyID + } else { + policyID = s.assignService.ResolvePolicyID(ownerID) + } + + engine, engineErr := storage.GetEngineByPolicyID(policyID) + if engineErr != nil { + engine = storage.GetDefaultEngine() + policyID = uint64(config.AppConfig.Storage.DefaultPolicyID) + } + if engine == nil { + return nil, errors.New("存储引擎未初始化") + } + + // 打开上传文件 + src, err := fileHeader.Open() + if err != nil { + return nil, fmt.Errorf("打开上传文件失败: %w", err) + } + defer src.Close() + + // 读取文件内容到临时文件(用于计算MD5) + tempFile, err := os.CreateTemp("", "upload_*") + if err != nil { + return nil, fmt.Errorf("创建临时文件失败: %w", err) + } + defer os.Remove(tempFile.Name()) + + size, err := io.Copy(tempFile, src) + if err != nil { + tempFile.Close() + return nil, fmt.Errorf("保存临时文件失败: %w", err) + } + tempFile.Close() + + // 计算文件MD5 + md5, err := utils.FileMD5(tempFile.Name()) + if err != nil { + return nil, fmt.Errorf("计算MD5失败: %w", err) + } + + // 秒传检查: 如果MD5相同的文件已存在, 直接创建引用 + existing, _ := s.fileRepo.FindByMD5(md5) + if existing != nil { + newFile := &model.File{ + Name: fileHeader.Filename, + Extension: utils.GetExtension(fileHeader.Filename), + FolderID: folderID, + OwnerID: ownerID, + Size: size, + MD5: md5, + StorageKey: existing.StorageKey, + StoragePolicyID: existing.StoragePolicyID, + MimeType: utils.GetMimeType(utils.GetExtension(fileHeader.Filename)), + Status: 1, + } + if err := s.fileRepo.Create(newFile); err != nil { + return nil, fmt.Errorf("创建文件记录失败: %w", err) + } + s.userRepo.UpdateStorageUsed(ownerID, size) + return s.toFileVO(newFile), nil + } + + // 正常上传: 生成存储路径 -> 上传到存储引擎 -> 保存元数据 + ext := utils.GetExtension(fileHeader.Filename) + storageKey := storage.GenerateStorageKey(md5, ext) + + reader, err := os.Open(tempFile.Name()) + if err != nil { + return nil, fmt.Errorf("打开临时文件失败: %w", err) + } + defer reader.Close() + + if err := engine.Upload(storageKey, reader, size); err != nil { + return nil, fmt.Errorf("存储文件失败: %w", err) + } + + newFile := &model.File{ + Name: fileHeader.Filename, + Extension: ext, + FolderID: folderID, + OwnerID: ownerID, + Size: size, + MD5: md5, + StorageKey: storageKey, + StoragePolicyID: policyID, + MimeType: utils.GetMimeType(ext), + Status: 1, + } + + if err := s.fileRepo.Create(newFile); err != nil { + engine.Delete(storageKey) + return nil, fmt.Errorf("创建文件记录失败: %w", err) + } + + s.userRepo.UpdateStorageUsed(ownerID, size) + return s.toFileVO(newFile), nil +} + +// GetByID 根据ID获取文件 +func (s *FileService) GetByID(id uint64, ownerID uint64) (*dto.FileVO, error) { + file, err := s.fileRepo.FindByID(id) + if err != nil { + return nil, errors.New("文件不存在") + } + if file.OwnerID != ownerID { + return nil, errors.New("无权访问此文件") + } + return s.toFileVO(file), nil +} + +// Download 下载文件(检查归属) +func (s *FileService) Download(id uint64, ownerID uint64) (io.ReadCloser, *model.File, error) { + file, err := s.fileRepo.FindByID(id) + if err != nil { + return nil, nil, errors.New("文件不存在") + } + if file.OwnerID != ownerID { + return nil, nil, errors.New("无权下载此文件") + } + return s.downloadFile(file, id) +} + +// DownloadByFileID 下载文件(不检查归属, 用于分享下载) +func (s *FileService) DownloadByFileID(fileID uint64) (io.ReadCloser, *model.File, error) { + file, err := s.fileRepo.FindByID(fileID) + if err != nil { + return nil, nil, errors.New("文件不存在") + } + return s.downloadFile(file, fileID) +} + +// downloadFile 内部下载实现 +func (s *FileService) downloadFile(file *model.File, id uint64) (io.ReadCloser, *model.File, error) { + engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID) + if err != nil { + return nil, nil, fmt.Errorf("获取存储引擎失败: %w", err) + } + + reader, err := engine.Download(file.StorageKey) + if err != nil { + return nil, nil, fmt.Errorf("读取文件失败: %w", err) + } + + s.fileRepo.IncrementDownloadCount(id) + return reader, file, nil +} + +// Rename 重命名文件 +// Rename 重命名文件 +// version: 乐观锁版本号, 0=不检查版本 +func (s *FileService) Rename(id uint64, newName string, ownerID uint64, version int) error { + file, err := s.fileRepo.FindByID(id) + if err != nil { + return errors.New("文件不存在") + } + if file.OwnerID != ownerID { + return errors.New("无权操作此文件") + } + + // 乐观锁检查 + if version > 0 && file.Version != version { + return fmt.Errorf("文件已被其他人修改, 当前版本: %d, 请刷新后重试", file.Version) + } + + file.Name = newName + file.Extension = utils.GetExtension(newName) + file.Version++ + + result := config.DB.Model(&model.File{}). + Where("id = ? AND version = ?", id, file.Version-1). + Updates(map[string]interface{}{ + "name": file.Name, + "extension": file.Extension, + "version": file.Version, + }) + + if result.RowsAffected == 0 { + return fmt.Errorf("文件已被其他人修改, 请刷新后重试") + } + return result.Error +} + +// Move 移动文件到目标文件夹 +func (s *FileService) Move(id uint64, targetFolderID uint64, ownerID uint64) error { + file, err := s.fileRepo.FindByID(id) + if err != nil { + return errors.New("文件不存在") + } + if file.OwnerID != ownerID { + return errors.New("无权操作此文件") + } + + if targetFolderID > 0 { + folder, err := s.folderRepo.FindByID(targetFolderID) + if err != nil { + return errors.New("目标文件夹不存在") + } + if folder.OwnerID != ownerID { + return errors.New("无权移动到此文件夹") + } + } + + file.FolderID = targetFolderID + return s.fileRepo.Update(file) +} + +// SoftDelete 移入回收站 +func (s *FileService) SoftDelete(id uint64, ownerID uint64) error { + file, err := s.fileRepo.FindByID(id) + if err != nil { + return errors.New("文件不存在") + } + if file.OwnerID != ownerID { + return errors.New("无权操作此文件") + } + + return s.fileRepo.SoftDelete(id) +} + +// Restore 从回收站恢复 +func (s *FileService) Restore(id uint64, ownerID uint64) error { + file, err := s.fileRepo.FindByID(id) + if err != nil { + return errors.New("文件不存在") + } + if file.OwnerID != ownerID { + return errors.New("无权操作此文件") + } + + return s.fileRepo.Restore(id) +} + +// HardDelete 永久删除(同时删除存储文件) +func (s *FileService) HardDelete(id uint64, ownerID uint64) error { + file, err := s.fileRepo.FindByID(id) + if err != nil { + return errors.New("文件不存在") + } + if file.OwnerID != ownerID { + return errors.New("无权操作此文件") + } + + // 根据文件的存储策略获取引擎并删除 + engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID) + if err == nil && engine != nil { + engine.Delete(file.StorageKey) + } + + s.userRepo.UpdateStorageUsed(ownerID, -file.Size) + return s.fileRepo.HardDelete(id) +} + +// GetDownloadURL 生成文件下载链接 +func (s *FileService) GetDownloadURL(id uint64) (string, error) { + file, err := s.fileRepo.FindByID(id) + if err != nil { + return "", errors.New("文件不存在") + } + return s.generateFileURL(file, "download") +} + +// GetPreviewURL 生成文件预览链接 +func (s *FileService) GetPreviewURL(id uint64) (string, error) { + file, err := s.fileRepo.FindByID(id) + if err != nil { + return "", errors.New("文件不存在") + } + return s.generateFileURL(file, "preview") +} + +// 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 + } + + // 本地存储: 使用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 + } + + // MinIO存储: 生成预签名URL并替换为Nginx代理地址 + if minioEngine, ok := engine.(*storage.MinIOEngine); ok { + presignedURL, err := minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour) + if err != nil { + return fmt.Sprintf("/api/file/%d/%s", file.ID, urlType), 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 +} + +// getStoragePolicyConfig 获取存储策略配置 +func getStoragePolicyConfig(policyID uint64) *model.PolicyConfig { + var policy model.StoragePolicy + if err := config.DB.First(&policy, policyID).Error; err != nil { + return nil + } + return &policy.Config +} + +// replaceMinIOEndpoint 替换URL中的MinIO地址为Nginx代理地址 +func replaceMinIOEndpoint(url, minioEndpoint, nginxEndpoint string) string { + oldHTTP := "http://" + minioEndpoint + if len(url) > len(oldHTTP) && url[:len(oldHTTP)] == oldHTTP { + return nginxEndpoint + url[len(oldHTTP):] + } + oldHTTPS := "https://" + minioEndpoint + if len(url) > len(oldHTTPS) && url[:len(oldHTTPS)] == oldHTTPS { + return nginxEndpoint + url[len(oldHTTPS):] + } + return url +} + +// GetByKey 根据fileKey(storageKey)查询文件信息 +func (s *FileService) GetByKey(storageKey string) (*dto.FileVO, error) { + var file model.File + err := config.DB.Where("storage_key = ?", storageKey).First(&file).Error + if err != nil { + return nil, errors.New("文件不存在") + } + return s.toFileVO(&file), nil +} + +// Search 搜索文件(基于权限: 自己的 + 被授权的) +func (s *FileService) Search(userID uint64, keyword string) ([]dto.FileVO, error) { + roleIDs := s.getUserRoleIDs(userID) + files, err := s.fileRepo.SearchByName(userID, roleIDs, keyword) + if err != nil { + return nil, err + } + + vos := make([]dto.FileVO, 0, len(files)) + for _, f := range files { + vo := s.toFileVO(&f) + vos = append(vos, *vo) + } + return vos, nil +} + +// SearchByType 按文件类型搜索 +func (s *FileService) SearchByType(ownerID uint64, extensions []string) ([]dto.FileVO, error) { + files, err := s.fileRepo.SearchByType(ownerID, extensions) + if err != nil { + return nil, err + } + + vos := make([]dto.FileVO, 0, len(files)) + for _, f := range files { + vos = append(vos, *s.toFileVO(&f)) + } + return vos, nil +} + +// ListTrashed 查询回收站 +func (s *FileService) ListTrashed(ownerID uint64) ([]dto.FileVO, error) { + files, err := s.fileRepo.ListTrashed(ownerID) + if err != nil { + return nil, err + } + + vos := make([]dto.FileVO, 0, len(files)) + for _, f := range files { + vos = append(vos, *s.toFileVO(&f)) + } + return vos, nil +} + +// GetDownloadPath 获取文件的本地存储完整路径(用于静态文件服务) +func (s *FileService) GetDownloadPath(storageKey string) string { + return filepath.Join(config.AppConfig.Storage.LocalBasePath, storageKey) +} + +// toFileVO 转换为视图对象 +func (s *FileService) toFileVO(file *model.File) *dto.FileVO { + vo := &dto.FileVO{ + ID: file.ID, + Name: file.Name, + Extension: file.Extension, + FolderID: file.FolderID, + 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, _ := s.generateFileURL(file, "download") + previewURL, _ := s.generateFileURL(file, "preview") + vo.DownloadURL = downloadURL + vo.PreviewURL = previewURL + } + + return vo +} + +// getUserRoleIDs 获取用户的角色ID列表 +func (s *FileService) getUserRoleIDs(userID uint64) []uint64 { + 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) + } + return roleIDs +} diff --git a/server/service/folder_service.go b/server/service/folder_service.go new file mode 100644 index 0000000..2c3f6c1 --- /dev/null +++ b/server/service/folder_service.go @@ -0,0 +1,206 @@ +package service + +import ( + "errors" + "fmt" + + "seeyon-filesystem/config" + "seeyon-filesystem/dto" + "seeyon-filesystem/model" + "seeyon-filesystem/repository" +) + +// FolderService 文件夹业务服务 +type FolderService struct { + folderRepo *repository.FolderRepository + fileRepo *repository.FileRepository +} + +// NewFolderService 创建文件夹服务实例 +func NewFolderService() *FolderService { + return &FolderService{ + folderRepo: repository.NewFolderRepository(), + fileRepo: repository.NewFileRepository(), + } +} + +// Create 创建文件夹 +func (s *FolderService) Create(req *dto.CreateFolderRequest, ownerID uint64) (*dto.FolderVO, error) { + // 构建物化路径 + parentPath := "/" + if req.ParentID > 0 { + parent, err := s.folderRepo.FindByID(req.ParentID) + if err != nil { + return nil, errors.New("父文件夹不存在") + } + if parent.OwnerID != ownerID { + return nil, errors.New("无权访问父文件夹") + } + parentPath = parent.Path + } + + // 检查同名文件夹 + existing, _ := s.folderRepo.FindByNameAndParent(req.Name, req.ParentID, ownerID) + if existing != nil { + return nil, errors.New("同名文件夹已存在") + } + + folder := &model.Folder{ + Name: req.Name, + ParentID: req.ParentID, + OwnerID: ownerID, + Path: parentPath, // 先创建, 获取ID后再更新路径 + Status: 1, + } + + if err := s.folderRepo.Create(folder); err != nil { + 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 +} + +// GetByID 根据ID获取文件夹 +func (s *FolderService) GetByID(id uint64, ownerID uint64) (*dto.FolderVO, error) { + folder, err := s.folderRepo.FindByID(id) + if err != nil { + return nil, errors.New("文件夹不存在") + } + if 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 + roleIDs := s.getUserRoleIDs(userID) + + // 如果指定了文件夹ID, 验证访问权限 + if folderID > 0 { + folder, err := s.folderRepo.FindByID(folderID) + if err != nil { + return nil, errors.New("文件夹不存在") + } + if folder.OwnerID != userID { + return nil, errors.New("无权访问此文件夹") + } + } + + // 查询子文件夹(暂时只查自己创建的) + folders, err := s.folderRepo.ListByParent(folderID, userID) + if err != nil { + return nil, err + } + + // 查询子文件(基于权限: 自己的 + 被授权的) + files, err := s.fileRepo.ListByFolder(folderID, userID, roleIDs) + if err != nil { + return nil, err + } + + // 组装响应 + folderVOs := make([]dto.FolderVO, 0, len(folders)) + for _, f := range folders { + folderVOs = append(folderVOs, *s.toFolderVO(&f)) + } + + fileVOs := make([]dto.FileVO, 0, len(files)) + for _, f := range files { + fileVOs = append(fileVOs, *s.toFileVO(&f)) + } + + return &dto.FileListVO{ + Folders: folderVOs, + Files: fileVOs, + }, nil +} + +// 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 { + return errors.New("无权操作此文件夹") + } + + // 乐观锁检查 + if version > 0 && folder.Version != version { + return fmt.Errorf("文件夹已被其他人修改, 当前版本: %d, 请刷新后重试", folder.Version) + } + + newVersion := folder.Version + 1 + result := config.DB.Model(&model.Folder{}). + Where("id = ? AND version = ?", id, folder.Version). + Updates(map[string]interface{}{ + "name": newName, + "version": newVersion, + }) + + if result.RowsAffected == 0 { + return fmt.Errorf("文件夹已被其他人修改, 请刷新后重试") + } + return result.Error +} + +// Delete 删除文件夹(移入回收站) +func (s *FolderService) Delete(id uint64, ownerID uint64) error { + folder, err := s.folderRepo.FindByID(id) + if err != nil { + return errors.New("文件夹不存在") + } + if folder.OwnerID != ownerID { + return errors.New("无权操作此文件夹") + } + + return s.folderRepo.SoftDelete(id) +} + +// toFolderVO 转换为视图对象 +func (s *FolderService) toFolderVO(folder *model.Folder) *dto.FolderVO { + return &dto.FolderVO{ + ID: folder.ID, + Name: folder.Name, + ParentID: folder.ParentID, + Path: folder.Path, + Version: folder.Version, + CreatedAt: folder.CreatedAt, + UpdatedAt: folder.UpdatedAt, + } +} + +// getUserRoleIDs 获取用户的角色ID列表 +func (s *FolderService) getUserRoleIDs(userID uint64) []uint64 { + 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) + } + return roleIDs +} + +// 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, + } +} diff --git a/server/service/permission_service.go b/server/service/permission_service.go new file mode 100644 index 0000000..63244fa --- /dev/null +++ b/server/service/permission_service.go @@ -0,0 +1,144 @@ +package service + +import ( + "errors" + + "seeyon-filesystem/model" + "seeyon-filesystem/repository" +) + +// PermissionService RBAC权限服务 +// 管理角色、权限的CRUD以及权限检查 +type PermissionService struct { + roleRepo *repository.RoleRepository + permissionRepo *repository.PermissionRepository + userRoleRepo *repository.UserRoleRepository + userRepo *repository.UserRepository +} + +// NewPermissionService 创建权限服务实例 +func NewPermissionService() *PermissionService { + return &PermissionService{ + roleRepo: repository.NewRoleRepository(), + permissionRepo: repository.NewPermissionRepository(), + userRoleRepo: repository.NewUserRoleRepository(), + userRepo: repository.NewUserRepository(), + } +} + +// ========== 角色管理 ========== + +// CreateRole 创建角色 +func (s *PermissionService) CreateRole(role *model.Role) error { + existing, _ := s.roleRepo.FindByName(role.Name) + if existing != nil { + return errors.New("角色标识已存在") + } + return s.roleRepo.Create(role) +} + +// GetRole 获取角色详情 +func (s *PermissionService) GetRole(id uint64) (*model.Role, error) { + return s.roleRepo.FindByID(id) +} + +// ListRoles 获取所有角色列表 +func (s *PermissionService) ListRoles() ([]model.Role, error) { + return s.roleRepo.List() +} + +// UpdateRole 更新角色 +func (s *PermissionService) UpdateRole(role *model.Role) error { + existing, err := s.roleRepo.FindByID(role.ID) + if err != nil { + return errors.New("角色不存在") + } + // 系统内置角色不允许修改标识 + if existing.IsSystem == 1 && existing.Name != role.Name { + return errors.New("系统内置角色不允许修改标识") + } + role.IsSystem = existing.IsSystem + return s.roleRepo.Update(role) +} + +// DeleteRole 删除角色 +func (s *PermissionService) DeleteRole(id uint64) error { + role, err := s.roleRepo.FindByID(id) + if err != nil { + return errors.New("角色不存在") + } + if role.IsSystem == 1 { + return errors.New("系统内置角色不允许删除") + } + return s.roleRepo.Delete(id) +} + +// SetRolePermissions 设置角色权限 +func (s *PermissionService) SetRolePermissions(roleID uint64, permissionIDs []uint64) error { + _, err := s.roleRepo.FindByID(roleID) + if err != nil { + return errors.New("角色不存在") + } + return s.roleRepo.SetPermissions(roleID, permissionIDs) +} + +// ========== 权限管理 ========== + +// CreatePermission 创建权限 +func (s *PermissionService) CreatePermission(perm *model.Permission) error { + existing, _ := s.permissionRepo.FindByCode(perm.Code) + if existing != nil { + return errors.New("权限标识已存在") + } + return s.permissionRepo.Create(perm) +} + +// ListPermissions 获取所有权限列表 +func (s *PermissionService) ListPermissions() ([]model.Permission, error) { + return s.permissionRepo.List() +} + +// UpdatePermission 更新权限 +func (s *PermissionService) UpdatePermission(perm *model.Permission) error { + return s.permissionRepo.Update(perm) +} + +// DeletePermission 删除权限 +func (s *PermissionService) DeletePermission(id uint64) error { + return s.permissionRepo.Delete(id) +} + +// ========== 用户角色管理 ========== + +// SetUserRoles 设置用户角色 +func (s *PermissionService) SetUserRoles(userID uint64, roleIDs []uint64) error { + _, err := s.userRepo.FindByID(userID) + if err != nil { + return errors.New("用户不存在") + } + return s.userRoleRepo.SetUserRoles(userID, roleIDs) +} + +// GetUserRoles 获取用户的角色列表 +func (s *PermissionService) GetUserRoles(userID uint64) ([]model.Role, error) { + return s.userRoleRepo.GetUserRoles(userID) +} + +// GetUserPermissions 获取用户的权限列表(通过角色计算) +func (s *PermissionService) GetUserPermissions(userID uint64) ([]string, error) { + return s.permissionRepo.GetUserPermissionCodes(userID) +} + +// HasPermission 检查用户是否拥有指定权限 +func (s *PermissionService) HasPermission(userID uint64, permissionCode string) (bool, error) { + codes, err := s.permissionRepo.GetUserPermissionCodes(userID) + if err != nil { + return false, err + } + for _, code := range codes { + if code == permissionCode { + return true, nil + } + } + return false, nil +} diff --git a/server/service/share_service.go b/server/service/share_service.go new file mode 100644 index 0000000..3e59d66 --- /dev/null +++ b/server/service/share_service.go @@ -0,0 +1,203 @@ +package service + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "strings" + "time" + + "seeyon-filesystem/config" + "seeyon-filesystem/dto" + "seeyon-filesystem/model" + "seeyon-filesystem/repository" + "seeyon-filesystem/storage" + "seeyon-filesystem/utils" +) + +// ShareService 分享业务服务 +type ShareService struct { + shareRepo *repository.ShareRepository + fileRepo *repository.FileRepository +} + +// NewShareService 创建分享服务实例 +func NewShareService() *ShareService { + return &ShareService{ + shareRepo: repository.NewShareRepository(), + fileRepo: repository.NewFileRepository(), + } +} + +// Create 创建分享链接 +func (s *ShareService) Create(req *dto.ShareCreateRequest, ownerID uint64) (*dto.ShareVO, error) { + // 验证文件存在 + file, err := s.fileRepo.FindByID(req.FileID) + if err != nil { + return nil, errors.New("文件不存在") + } + if file.OwnerID != ownerID { + return nil, errors.New("无权分享此文件") + } + + // 生成分享码 + shareCode, err := generateShareCode() + if err != nil { + return nil, errors.New("生成分享码失败") + } + + share := &model.Share{ + FileID: req.FileID, + OwnerID: ownerID, + ShareCode: shareCode, + AllowPreview: 1, + AllowDownload: 1, + Status: 1, + } + + // 设置密码 + if req.Password != "" { + hashed, _ := utils.HashPassword(req.Password) + share.Password = hashed + } + + // 设置过期时间 + if req.ExpireHours > 0 { + expireAt := time.Now().Add(time.Duration(req.ExpireHours) * time.Hour) + share.ExpireAt = &expireAt + } + + share.MaxDownload = req.MaxDownload + + // 设置权限 + share.AllowPreview = int8(req.AllowPreview) + share.AllowDownload = int8(req.AllowDownload) + // 如果都是0(未传), 默认允许 + if share.AllowPreview == 0 && share.AllowDownload == 0 { + share.AllowPreview = 1 + share.AllowDownload = 1 + } + + if err := s.shareRepo.Create(share); err != nil { + return nil, errors.New("创建分享失败") + } + + return s.toShareVO(share), nil +} + +// GetByCode 通过分享码获取分享信息(公开访问) +func (s *ShareService) GetByCode(code string) (*dto.ShareFileVO, *model.Share, error) { + share, err := s.shareRepo.FindByShareCode(code) + if err != nil { + return nil, nil, errors.New("分享链接不存在或已失效") + } + + // 检查过期 + if share.ExpireAt != nil && share.ExpireAt.Before(time.Now()) { + return nil, nil, errors.New("分享链接已过期") + } + + // 检查下载次数 + if share.MaxDownload > 0 && share.DownloadCount >= share.MaxDownload { + return nil, nil, errors.New("下载次数已达上限") + } + + // 获取文件信息 + file, err := s.fileRepo.FindByID(share.FileID) + if err != nil { + return nil, nil, errors.New("文件不存在") + } + + // 生成预览URL + previewURL := "" + if share.AllowPreview == 1 && file.StorageKey != "" { + engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID) + if err == nil { + if localEngine, ok := engine.(*storage.LocalEngine); ok { + previewURL = localEngine.GetURL(file.StorageKey) + } else if minioEngine, ok := engine.(*storage.MinIOEngine); ok { + previewURL = minioEngine.GetPreviewURL(file.StorageKey) + } + } + } + + fileVO := &dto.ShareFileVO{ + Name: file.Name, + Extension: file.Extension, + Size: file.Size, + MimeType: file.MimeType, + PreviewURL: previewURL, + AllowPreview: share.AllowPreview == 1, + AllowDownload: share.AllowDownload == 1, + CreatedAt: file.CreatedAt, + } + + return fileVO, share, nil +} + +// VerifyPassword 验证分享密码 +func (s *ShareService) VerifyPassword(shareID uint64, password string) bool { + share, err := s.shareRepo.FindByID(shareID) + if err != nil { + return false + } + return utils.CheckPassword(password, share.Password) +} + +// ListByOwner 查询用户的分享列表 +func (s *ShareService) ListByOwner(ownerID uint64) ([]dto.ShareVO, error) { + shares, err := s.shareRepo.ListByOwner(ownerID) + if err != nil { + return nil, err + } + + vos := make([]dto.ShareVO, 0, len(shares)) + for i := range shares { + vos = append(vos, *s.toShareVO(&shares[i])) + } + return vos, nil +} + +// Cancel 取消分享 +func (s *ShareService) Cancel(id uint64, ownerID uint64) error { + share, err := s.shareRepo.FindByID(id) + if err != nil { + return errors.New("分享不存在") + } + if share.OwnerID != ownerID { + return errors.New("无权操作此分享") + } + return s.shareRepo.Cancel(id) +} + +// toShareVO 转换为视图对象 +func (s *ShareService) toShareVO(share *model.Share) *dto.ShareVO { + // 生成完整分享链接 + publicURL := config.AppConfig.Server.PublicURL + if publicURL == "" { + publicURL = "http://localhost:8080" + } + publicURL = strings.TrimRight(publicURL, "/") + + return &dto.ShareVO{ + ID: share.ID, + ShareCode: share.ShareCode, + ShareURL: publicURL + "/s/" + share.ShareCode, + HasPassword: share.Password != "", + ExpireAt: share.ExpireAt, + MaxDownload: share.MaxDownload, + DownloadCount: share.DownloadCount, + AllowPreview: share.AllowPreview, + AllowDownload: share.AllowDownload, + CreatedAt: share.CreatedAt, + } +} + +// generateShareCode 生成8字节随机分享码(16位十六进制字符串) +func generateShareCode() (string, error) { + bytes := make([]byte, 8) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} diff --git a/server/service/sso_service.go b/server/service/sso_service.go new file mode 100644 index 0000000..c7ffc82 --- /dev/null +++ b/server/service/sso_service.go @@ -0,0 +1,372 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "seeyon-filesystem/config" + "seeyon-filesystem/model" + "seeyon-filesystem/repository" + "seeyon-filesystem/utils" +) + +// SSOService SSO单点登录服务 +// 支持 OAuth2.0 / OIDC 协议 +type SSOService struct { + ssoRepo *repository.SSORepository + userRepo *repository.UserRepository + roleRepo *repository.RoleRepository + userRoleRepo *repository.UserRoleRepository +} + +// NewSSOService 创建SSO服务实例 +func NewSSOService() *SSOService { + return &SSOService{ + ssoRepo: repository.NewSSORepository(), + userRepo: repository.NewUserRepository(), + roleRepo: repository.NewRoleRepository(), + userRoleRepo: repository.NewUserRoleRepository(), + } +} + +// GetAuthURL 获取SSO授权跳转URL +// 用户点击SSO登录按钮时调用, 返回第三方授权页面地址 +func (s *SSOService) GetAuthURL(providerID uint64, state string) (string, error) { + provider, err := s.ssoRepo.FindProviderByID(providerID) + if err != nil { + return "", errors.New("SSO提供商不存在") + } + if provider.Status != 1 { + return "", errors.New("SSO提供商已禁用") + } + + cfg := provider.Config + + switch provider.Type { + case "oauth2", "oidc": + // 构建OAuth2授权URL + params := url.Values{} + params.Set("client_id", cfg.ClientID) + params.Set("redirect_uri", cfg.RedirectURI) + params.Set("response_type", "code") + params.Set("scope", cfg.Scopes) + params.Set("state", state) + return cfg.AuthURL + "?" + params.Encode(), nil + + case "cas": + // CAS协议: 重定向到CAS登录页 + serviceURL := url.QueryEscape(cfg.RedirectURI) + return cfg.CasServerURL + "/login?service=" + serviceURL, nil + + default: + return "", fmt.Errorf("不支持的SSO类型: %s", provider.Type) + } +} + +// HandleCallback 处理SSO回调 +// 用户在第三方授权后回调此接口, 用授权码换取用户信息并完成登录 +func (s *SSOService) HandleCallback(providerID uint64, code string) (*LoginResult, error) { + provider, err := s.ssoRepo.FindProviderByID(providerID) + if err != nil { + return nil, errors.New("SSO提供商不存在") + } + + // 1. 用授权码换取Token和用户信息 + ssoUser, accessToken, refreshToken, expiresAt, err := s.exchangeToken(provider, code) + if err != nil { + return nil, fmt.Errorf("获取SSO用户信息失败: %w", err) + } + + // 2. 查找或创建本地用户 + user, err := s.findOrCreateUser(provider, ssoUser) + if err != nil { + return nil, fmt.Errorf("创建本地用户失败: %w", err) + } + + // 3. 检查用户状态 + if user.Status != 1 { + return nil, errors.New("账号已被禁用") + } + + // 4. 保存/更新SSO会话 + s.saveSession(user.ID, providerID, ssoUser["id"], accessToken, refreshToken, expiresAt) + + // 5. 生成本地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 &LoginResult{ + Token: token, + Username: user.Username, + Nickname: user.Nickname, + Role: user.Role, + }, nil +} + +// LoginResult SSO登录结果 +type LoginResult struct { + Token string `json:"token"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Role string `json:"role"` +} + +// ssoUser SSO用户信息 +type ssoUser map[string]string + +// exchangeToken 用授权码换取Token和用户信息 +func (s *SSOService) exchangeToken(provider *model.SSOProvider, code string) (ssoUser, string, string, time.Time, error) { + cfg := provider.Config + + switch provider.Type { + case "oauth2", "oidc": + return s.exchangeOAuth2Token(cfg, code) + case "cas": + return s.exchangeCASTicket(cfg, code) + default: + return nil, "", "", time.Time{}, fmt.Errorf("不支持的SSO类型: %s", provider.Type) + } +} + +// exchangeOAuth2Token OAuth2.0 授权码换取Token +func (s *SSOService) exchangeOAuth2Token(cfg model.SSOConfig, code string) (ssoUser, string, string, time.Time, error) { + // 1. 用code换取access_token + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("client_id", cfg.ClientID) + data.Set("client_secret", cfg.ClientSecret) + data.Set("redirect_uri", cfg.RedirectURI) + + resp, err := http.Post(cfg.TokenURL, "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) + if err != nil { + return nil, "", "", time.Time{}, fmt.Errorf("请求Token失败: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, "", "", time.Time{}, fmt.Errorf("获取Token失败: HTTP %d, %s", resp.StatusCode, string(body)) + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, "", "", time.Time{}, fmt.Errorf("解析Token响应失败: %w", err) + } + + // 2. 用access_token获取用户信息 + userInfo, err := s.fetchOAuth2UserInfo(cfg.UserInfoURL, tokenResp.AccessToken) + if err != nil { + return nil, "", "", time.Time{}, err + } + + expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + return userInfo, tokenResp.AccessToken, tokenResp.RefreshToken, expiresAt, nil +} + +// fetchOAuth2UserInfo 获取OAuth2用户信息 +func (s *SSOService) fetchOAuth2UserInfo(userInfoURL, accessToken string) (ssoUser, error) { + req, _ := http.NewRequest("GET", userInfoURL, nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求用户信息失败: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("获取用户信息失败: HTTP %d", resp.StatusCode) + } + + var raw map[string]interface{} + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("解析用户信息失败: %w", err) + } + + // 将嵌套结构展平为 map[string]string + user := make(ssoUser) + for k, v := range raw { + user[k] = fmt.Sprintf("%v", v) + } + + return user, nil +} + +// exchangeCASTicket CAS协议票据验证 +func (s *SSOService) exchangeCASTicket(cfg model.SSOConfig, ticket string) (ssoUser, string, string, time.Time, error) { + // CAS票据验证 + validateURL := fmt.Sprintf("%s/serviceValidate?ticket=%s&service=%s&format=JSON", + cfg.CasServerURL, ticket, url.QueryEscape(cfg.RedirectURI)) + + resp, err := http.Get(validateURL) + if err != nil { + return nil, "", "", time.Time{}, fmt.Errorf("CAS票据验证失败: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + var casResp struct { + ServiceResponse struct { + AuthenticationSuccess struct { + User string `json:"user"` + Attributes map[string]interface{} `json:"attributes"` + } `json:"authenticationSuccess"` + AuthenticationFailure *struct { + Code string `json:"code"` + } `json:"authenticationFailure,omitempty"` + } `json:"serviceResponse"` + } + + if err := json.Unmarshal(body, &casResp); err != nil { + return nil, "", "", time.Time{}, fmt.Errorf("解析CAS响应失败: %w", err) + } + + if casResp.ServiceResponse.AuthenticationFailure != nil { + return nil, "", "", time.Time{}, fmt.Errorf("CAS认证失败: %s", casResp.ServiceResponse.AuthenticationFailure.Code) + } + + success := casResp.ServiceResponse.AuthenticationSuccess + user := make(ssoUser) + user["id"] = success.User + user["username"] = success.User + + // 提取属性 + if email, ok := success.Attributes["email"]; ok { + user["email"] = fmt.Sprintf("%v", email) + } + if name, ok := success.Attributes["name"]; ok { + user["nickname"] = fmt.Sprintf("%v", name) + } + + return user, ticket, "", time.Now().Add(24 * time.Hour), nil +} + +// findOrCreateUser 查找或创建本地用户 +func (s *SSOService) findOrCreateUser(provider *model.SSOProvider, ssoUserData ssoUser) (*model.User, error) { + externalID := ssoUserData["id"] + if externalID == "" { + externalID = ssoUserData["username"] + } + + // 1. 通过SSO会话查找已有用户 + session, _ := s.ssoRepo.FindSessionByExternalID(provider.ID, externalID) + if session != nil { + return s.userRepo.FindByID(session.UserID) + } + + // 2. 通过用户名查找已有用户 + username := ssoUserData["username"] + if username == "" { + username = externalID + } + + user, _ := s.userRepo.FindByUsername(username) + if user != nil { + return user, nil + } + + // 3. 自动创建用户 + if provider.AutoCreate != 1 { + return nil, errors.New("用户不存在且未启用自动创建") + } + + // 构建用户名: 前缀 + 外部ID, 避免冲突 + localUsername := fmt.Sprintf("sso_%s_%s", provider.Type, username) + + newUser := &model.User{ + Username: localUsername, + Password: "_SSO_NO_PASSWORD_", // SSO用户无本地密码 + Email: ssoUserData["email"], + Nickname: ssoUserData["nickname"], + Role: provider.DefaultRole, + Status: 1, + } + + if newUser.Nickname == "" { + newUser.Nickname = username + } + + if err := s.userRepo.Create(newUser); err != nil { + return nil, fmt.Errorf("创建用户失败: %w", err) + } + + // 分配默认角色 + role, _ := s.roleRepo.FindByName(provider.DefaultRole) + if role != nil { + s.userRoleRepo.SetUserRoles(newUser.ID, []uint64{role.ID}) + } + + return newUser, nil +} + +// saveSession 保存SSO会话 +func (s *SSOService) saveSession(userID, providerID uint64, externalID, accessToken, refreshToken string, expiresAt time.Time) { + session := &model.SSOSession{ + UserID: userID, + ProviderID: providerID, + ExternalID: externalID, + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresAt: expiresAt, + LastLoginAt: time.Now(), + } + + existing, _ := s.ssoRepo.FindSessionByExternalID(providerID, externalID) + if existing != nil { + existing.AccessToken = accessToken + existing.RefreshToken = refreshToken + existing.ExpiresAt = expiresAt + existing.LastLoginAt = time.Now() + s.ssoRepo.UpdateSession(existing) + } else { + s.ssoRepo.CreateSession(session) + } +} + +// ========== 管理接口 ========== + +// CreateProvider 创建SSO提供商 +func (s *SSOService) CreateProvider(provider *model.SSOProvider) error { + return s.ssoRepo.CreateProvider(provider) +} + +// ListProviders 获取所有提供商 +func (s *SSOService) ListProviders() ([]model.SSOProvider, error) { + return s.ssoRepo.ListAllProviders() +} + +// ListActiveProviders 获取启用的提供商(登录页展示) +func (s *SSOService) ListActiveProviders() ([]model.SSOProvider, error) { + return s.ssoRepo.ListProviders() +} + +// UpdateProvider 更新提供商 +func (s *SSOService) UpdateProvider(provider *model.SSOProvider) error { + return s.ssoRepo.UpdateProvider(provider) +} + +// DeleteProvider 删除提供商 +func (s *SSOService) DeleteProvider(id uint64) error { + return s.ssoRepo.DeleteProvider(id) +} diff --git a/server/sql/init.sql b/server/sql/init.sql new file mode 100644 index 0000000..6392bf7 --- /dev/null +++ b/server/sql/init.sql @@ -0,0 +1,222 @@ +-- ============================================= +-- 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/engine.go b/server/storage/engine.go new file mode 100644 index 0000000..6dc0fbf --- /dev/null +++ b/server/storage/engine.go @@ -0,0 +1,37 @@ +// Package storage 提供文件存储引擎抽象层 +// 通过接口解耦文件存储, 支持本地磁盘、MinIO、S3等多种后端 +package storage + +import ( + "io" +) + +// Engine 存储引擎接口 +// 所有存储后端(local/minio/s3/oss)都必须实现此接口 +type Engine interface { + // Upload 上传文件 + // storageKey: 存储路径标识, reader: 文件内容流, size: 文件大小 + // 返回实际的storageKey和错误 + Upload(storageKey string, reader io.Reader, size int64) error + + // Download 下载文件 + // 返回文件内容流, 调用方负责关闭 + Download(storageKey string) (io.ReadCloser, error) + + // Delete 删除文件 + Delete(storageKey string) error + + // Exists 判断文件是否存在 + Exists(storageKey string) (bool, error) + + // GetURL 获取文件访问URL + // 本地存储返回相对路径, 对象存储返回完整URL + GetURL(storageKey string) string +} + +// StorageResult 上传结果 +type StorageResult struct { + StorageKey string // 存储标识 + Size int64 // 文件大小 + MD5 string // 文件MD5 +} diff --git a/server/storage/factory.go b/server/storage/factory.go new file mode 100644 index 0000000..9aa17b7 --- /dev/null +++ b/server/storage/factory.go @@ -0,0 +1,112 @@ +package storage + +import ( + "fmt" + "log" + "seeyon-filesystem/config" + "seeyon-filesystem/model" +) + +var engines = make(map[string]Engine) +var policyEngines = make(map[uint64]Engine) + +func Register(policyType string, engine Engine) { + engines[policyType] = engine +} + +func GetEngine(policy *model.StoragePolicy) (Engine, error) { + if engine, ok := policyEngines[policy.ID]; ok { + return engine, nil + } + engine, ok := engines[policy.Type] + if !ok { + return nil, fmt.Errorf("不支持的存储类型: %s", policy.Type) + } + if policy.Type == "local" && policy.Config.BasePath != "" { + cfg := config.AppConfig.Storage + engine = NewLocalEngineWithCDN(policy.Config.BasePath, cfg.CDNHost, cfg.CDNPathPrefix) + } + policyEngines[policy.ID] = engine + return engine, nil +} + +func GetEngineByPolicyID(policyID uint64) (Engine, error) { + if engine, ok := policyEngines[policyID]; ok { + return engine, nil + } + var policy model.StoragePolicy + if err := config.DB.First(&policy, policyID).Error; err != nil { + return nil, fmt.Errorf("存储策略不存在: %d", policyID) + } + return GetEngine(&policy) +} + +func GetDefaultEngine() Engine { + if engine, ok := engines["local"]; ok { + return engine + } + return nil +} + +// InitEngines 初始化所有存储引擎 +func InitEngines(localBasePath string) { + cfg := config.AppConfig.Storage + + // 注册默认本地存储引擎(带CDN配置) + localEngine := NewLocalEngineWithCDN(localBasePath, cfg.CDNHost, cfg.CDNPathPrefix) + Register("local", localEngine) + + if cfg.CDNHost != "" { + log.Printf("CDN已启用: %s%s", cfg.CDNHost, cfg.CDNPathPrefix) + } + + // 从数据库加载存储策略 + var policies []model.StoragePolicy + if err := config.DB.Find(&policies).Error; err != nil { + log.Printf("加载存储策略失败: %v", err) + return + } + + for _, policy := range policies { + switch policy.Type { + case "local": + basePath := localBasePath + if policy.Config.BasePath != "" { + basePath = policy.Config.BasePath + } + policyEngines[policy.ID] = NewLocalEngineWithCDN(basePath, cfg.CDNHost, cfg.CDNPathPrefix) + log.Printf("初始化本地存储: %s -> %s (CDN: %s)", policy.Name, basePath, cfg.CDNHost+cfg.CDNPathPrefix) + + case "minio": + engine, err := NewMinIOEngine( + policy.Config.Endpoint, + policy.Config.AccessKey, + policy.Config.SecretKey, + policy.Config.Bucket, + policy.Config.UseSSL, + ) + if err != nil { + log.Printf("初始化MinIO失败 [%s]: %v", policy.Name, err) + continue + } + policyEngines[policy.ID] = engine + log.Printf("初始化MinIO: %s -> %s/%s", policy.Name, policy.Config.Endpoint, policy.Config.Bucket) + + case "s3", "oss": + log.Printf("S3/OSS暂未实现: %s", policy.Name) + } + } + + log.Printf("存储引擎初始化完成, 已加载 %d 个策略", len(policyEngines)) +} + +func GetMinIOEngine(policyID uint64) (*MinIOEngine, error) { + engine, err := GetEngineByPolicyID(policyID) + if err != nil { + return nil, err + } + if minioEngine, ok := engine.(*MinIOEngine); ok { + return minioEngine, nil + } + return nil, fmt.Errorf("策略 %d 不是MinIO类型", policyID) +} diff --git a/server/storage/local.go b/server/storage/local.go new file mode 100644 index 0000000..a23a7ec --- /dev/null +++ b/server/storage/local.go @@ -0,0 +1,113 @@ +package storage + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +// LocalEngine 本地磁盘存储引擎 +type LocalEngine struct { + BasePath string // 存储根目录 + CDNHost string // CDN地址, 如 http://localhost:3090 + CDNPathPrefix string // CDN路径前缀, 如 /public +} + +// NewLocalEngine 创建本地存储引擎实例 +func NewLocalEngine(basePath string) *LocalEngine { + os.MkdirAll(basePath, 0755) + return &LocalEngine{BasePath: basePath} +} + +// NewLocalEngineWithCDN 创建带CDN配置的本地存储引擎 +func NewLocalEngineWithCDN(basePath, cdnHost, cdnPathPrefix string) *LocalEngine { + os.MkdirAll(basePath, 0755) + return &LocalEngine{ + BasePath: basePath, + CDNHost: cdnHost, + CDNPathPrefix: cdnPathPrefix, + } +} + +// Upload 上传文件到本地磁盘 +func (e *LocalEngine) Upload(storageKey string, reader io.Reader, size int64) error { + fullPath := filepath.Join(e.BasePath, storageKey) + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("创建目录失败: %w", err) + } + + dst, err := os.Create(fullPath) + if err != nil { + return fmt.Errorf("创建文件失败: %w", err) + } + defer dst.Close() + + if _, err := io.Copy(dst, reader); err != nil { + os.Remove(fullPath) + return fmt.Errorf("写入文件失败: %w", err) + } + return nil +} + +// Download 从本地磁盘下载文件 +func (e *LocalEngine) Download(storageKey string) (io.ReadCloser, error) { + fullPath := filepath.Join(e.BasePath, storageKey) + file, err := os.Open(fullPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("文件不存在: %s", storageKey) + } + return nil, fmt.Errorf("打开文件失败: %w", err) + } + return file, nil +} + +// Delete 从本地磁盘删除文件 +func (e *LocalEngine) Delete(storageKey string) error { + fullPath := filepath.Join(e.BasePath, storageKey) + err := os.Remove(fullPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("删除文件失败: %w", err) + } + return nil +} + +// Exists 判断文件是否存在于本地磁盘 +func (e *LocalEngine) Exists(storageKey string) (bool, error) { + fullPath := filepath.Join(e.BasePath, storageKey) + _, err := os.Stat(fullPath) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +// GetURL 获取文件访问URL +// 配置了CDN: http://localhost:3090/public/2026/07/02/ab/xxx.txt +// 未配置CDN: /storage/2026/07/02/ab/xxx.txt +func (e *LocalEngine) GetURL(storageKey string) string { + if e.CDNHost != "" { + // 确保路径格式正确 + prefix := strings.TrimRight(e.CDNPathPrefix, "/") + key := strings.TrimLeft(storageKey, "/") + return fmt.Sprintf("%s%s/%s", e.CDNHost, prefix, key) + } + return "/storage/" + storageKey +} + +// GenerateStorageKey 生成存储路径 +func GenerateStorageKey(md5, extension string) string { + now := time.Now() + day := now.Format("2006/01/02") + if extension != "" { + return fmt.Sprintf("%s/%s/%s.%s", day, md5[:2], md5, extension) + } + return fmt.Sprintf("%s/%s/%s", day, md5[:2], md5) +} diff --git a/server/utils/file.go b/server/utils/file.go new file mode 100644 index 0000000..d19a6e0 --- /dev/null +++ b/server/utils/file.go @@ -0,0 +1,107 @@ +package utils + +import ( + "path/filepath" + "strings" +) + +// GetExtension 获取文件扩展名(不含点号, 全小写) +// 例如: "photo.JPG" -> "jpg" +func GetExtension(filename string) string { + ext := filepath.Ext(filename) + if ext == "" { + return "" + } + return strings.ToLower(ext[1:]) // 去掉开头的点号 +} + +// GetMimeType 根据文件扩展名获取MIME类型 +// 简单映射, 覆盖常见文件类型 +func GetMimeType(extension string) string { + mimeMap := map[string]string{ + // 图片 + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "png": "image/png", + "gif": "image/gif", + "bmp": "image/bmp", + "webp": "image/webp", + "svg": "image/svg+xml", + "ico": "image/x-icon", + + // 文档 + "pdf": "application/pdf", + "doc": "application/msword", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls": "application/vnd.ms-excel", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt": "application/vnd.ms-powerpoint", + "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + + // 文本 + "txt": "text/plain", + "html": "text/html", + "htm": "text/html", + "css": "text/css", + "js": "application/javascript", + "json": "application/json", + "xml": "application/xml", + "csv": "text/csv", + "md": "text/markdown", + + // 压缩包 + "zip": "application/zip", + "rar": "application/x-rar-compressed", + "7z": "application/x-7z-compressed", + "tar": "application/x-tar", + "gz": "application/gzip", + + // 音视频 + "mp3": "audio/mpeg", + "wav": "audio/wav", + "mp4": "video/mp4", + "avi": "video/x-msvideo", + "mov": "video/quicktime", + "wmv": "video/x-ms-wmv", + "flv": "video/x-flv", + "mkv": "video/x-matroska", + } + + if mime, ok := mimeMap[extension]; ok { + return mime + } + return "application/octet-stream" +} + +// IsImageFile 判断是否为图片文件 +func IsImageFile(extension string) bool { + imageExts := []string{"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "ico"} + for _, ext := range imageExts { + if extension == ext { + return true + } + } + return false +} + +// IsVideoFile 判断是否为视频文件 +func IsVideoFile(extension string) bool { + videoExts := []string{"mp4", "avi", "mov", "wmv", "flv", "mkv"} + for _, ext := range videoExts { + if extension == ext { + return true + } + } + return false +} + +// IsDocumentFile 判断是否为文档文件 +func IsDocumentFile(extension string) bool { + docExts := []string{"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv"} + for _, ext := range docExts { + if extension == ext { + return true + } + } + return false +} diff --git a/server/utils/hash.go b/server/utils/hash.go new file mode 100644 index 0000000..cef1b41 --- /dev/null +++ b/server/utils/hash.go @@ -0,0 +1,62 @@ +package utils + +import ( + "crypto/md5" + "crypto/sha256" + "encoding/hex" + "io" + "os" + + "golang.org/x/crypto/bcrypt" +) + +// HashPassword 使用bcrypt对密码进行哈希 +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +// CheckPassword 验证密码是否匹配 +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// MD5Hash 计算字符串的MD5值 +func MD5Hash(text string) string { + hash := md5.Sum([]byte(text)) + return hex.EncodeToString(hash[:]) +} + +// FileMD5 计算文件的MD5值 +// 用于秒传判断: 相同MD5的文件只需存储一份 +func FileMD5(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + + hash := md5.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + return hex.EncodeToString(hash.Sum(nil)), nil +} + +// FileSHA256 计算文件的SHA256值 +func FileSHA256(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + return hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/server/utils/jwt.go b/server/utils/jwt.go new file mode 100644 index 0000000..c219af1 --- /dev/null +++ b/server/utils/jwt.go @@ -0,0 +1,50 @@ +package utils + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// JWTClaims 自定义JWT声明 +type JWTClaims struct { + UserID uint64 `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// GenerateToken 生成JWT Token +// userID: 用户ID, username: 用户名, role: 角色, secret: 密钥, expirationHours: 过期时间(小时) +func GenerateToken(userID uint64, username, role, secret string, expirationHours int) (string, error) { + claims := JWTClaims{ + UserID: userID, + Username: username, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expirationHours) * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "seeyon-filesystem", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} + +// ParseToken 解析JWT Token +func ParseToken(tokenString, secret string) (*JWTClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token") +} diff --git a/server/utils/response.go b/server/utils/response.go new file mode 100644 index 0000000..90c0ebb --- /dev/null +++ b/server/utils/response.go @@ -0,0 +1,89 @@ +// Package utils 提供通用工具函数 +package utils + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Response 统一API响应结构 +type Response struct { + Code int `json:"code"` // 业务状态码, 0=成功 + Message string `json:"message"` // 提示信息 + Data interface{} `json:"data"` // 响应数据 +} + +// PageResponse 分页响应结构 +type PageResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data"` + Total int64 `json:"total"` // 总记录数 + Page int `json:"page"` // 当前页码 + Size int `json:"size"` // 每页大小 +} + +// Success 返回成功响应 +func Success(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{ + Code: 0, + Message: "success", + Data: data, + }) +} + +// SuccessWithMessage 返回带消息的成功响应 +func SuccessWithMessage(c *gin.Context, message string, data interface{}) { + c.JSON(http.StatusOK, Response{ + Code: 0, + Message: message, + Data: data, + }) +} + +// Error 返回错误响应 +func Error(c *gin.Context, httpCode int, bizCode int, message string) { + c.JSON(httpCode, Response{ + Code: bizCode, + Message: message, + Data: nil, + }) +} + +// BadRequest 返回400错误 +func BadRequest(c *gin.Context, message string) { + Error(c, http.StatusBadRequest, 400, message) +} + +// Unauthorized 返回401错误 +func Unauthorized(c *gin.Context, message string) { + Error(c, http.StatusUnauthorized, 401, message) +} + +// Forbidden 返回403错误 +func Forbidden(c *gin.Context, message string) { + Error(c, http.StatusForbidden, 403, message) +} + +// NotFound 返回404错误 +func NotFound(c *gin.Context, message string) { + Error(c, http.StatusNotFound, 404, message) +} + +// ServerError 返回500错误 +func ServerError(c *gin.Context, message string) { + Error(c, http.StatusInternalServerError, 500, message) +} + +// PageSuccess 返回分页成功响应 +func PageSuccess(c *gin.Context, data interface{}, total int64, page, size int) { + c.JSON(http.StatusOK, PageResponse{ + Code: 0, + Message: "success", + Data: data, + Total: total, + Page: page, + Size: size, + }) +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f15acd7 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + 文件管理系统 + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..efb59cb --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1989 @@ +{ + "name": "seeyon-filesystem-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "seeyon-filesystem-web", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.5", + "element-plus": "^2.5.5", + "pinia": "^2.1.7", + "vue": "^3.4.15", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.3", + "typescript": "^5.3.3", + "vite": "^5.0.11", + "vue-tsc": "^1.8.27" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "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==", + "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", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "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", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "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", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "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", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.2.tgz", + "integrity": "sha512-eNH9uP3wQoNqieEIHXiNvIVv+zO5sZDU0CAZq5b0zqSN06DD0/V9xIq1R/qm3rw5k3nBTM1JvpxhCfRbaFLzDQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.3" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "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", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "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", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "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", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.6.tgz", + "integrity": "sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..3b48d05 --- /dev/null +++ b/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "seeyon-filesystem-web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.6.5", + "element-plus": "^2.5.5", + "pinia": "^2.1.7", + "vue": "^3.4.15", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.3", + "typescript": "^5.3.3", + "vite": "^5.0.11", + "vue-tsc": "^1.8.27" + } +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..9691ce5 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts new file mode 100644 index 0000000..c9e9d5d --- /dev/null +++ b/web/src/api/auth.ts @@ -0,0 +1,110 @@ +/** + * 认证相关 API + * 包含: 登录/注册/SSO/权限 + */ +import request from './request' + +/** 登录请求参数 */ +interface LoginParams { + username: string + password: string +} + +/** 登录响应数据 */ +interface LoginResult { + token: string + username: string + nickname: string + role: string +} + +/** 注册请求参数 */ +interface RegisterParams { + username: string + password: string + email?: string + nickname?: string +} + +/** SSO提供商信息 */ +export interface SSOProvider { + id: number + name: string + display_name: string + icon: string + type: string +} + +/** 用户权限信息 */ +export interface UserPermissionVO { + roles: { id: number; name: string; display_name: string }[] + permissions: string[] +} + +// ========== 账密登录 ========== + +export function login(params: LoginParams) { + return request.post('/auth/login', params) +} + +export function register(params: RegisterParams) { + return request.post('/auth/register', params) +} + +export function getProfile() { + return request.get('/auth/profile') +} + +// ========== SSO 单点登录 ========== + +/** 获取启用的SSO提供商列表(登录页展示) */ +export function getSSOProviders() { + return request.get('/sso/providers') +} + +/** 获取SSO授权跳转URL */ +export function getSSOAuthURL(providerId: number) { + return request.get('/sso/auth/url', { + params: { provider_id: providerId }, + }) +} + +/** SSO回调(前端处理授权码) */ +export function ssoCallback(providerId: number, code: string) { + return request.post('/sso/callback', { + provider_id: providerId, + code, + }) +} + +// ========== 权限管理 ========== + +/** 获取当前用户的权限信息 */ +export function getUserPermissions() { + return request.get('/auth/permissions') +} + +/** 管理员: 获取所有角色 */ +export function getRoles() { + return request.get('/admin/roles') +} + +/** 管理员: 创建角色 */ +export function createRole(data: { name: string; display_name: string; description?: string }) { + return request.post('/admin/roles', data) +} + +/** 管理员: 设置角色权限 */ +export function setRolePermissions(roleId: number, permissionIds: number[]) { + return request.put(`/admin/roles/${roleId}/permissions`, { permission_ids: permissionIds }) +} + +/** 管理员: 获取所有权限 */ +export function getPermissions() { + return request.get('/admin/permissions') +} + +/** 管理员: 设置用户角色 */ +export function setUserRoles(userId: number, roleIds: number[]) { + return request.put(`/admin/users/${userId}/roles`, { role_ids: roleIds }) +} diff --git a/web/src/api/file.ts b/web/src/api/file.ts new file mode 100644 index 0000000..6bd5fa7 --- /dev/null +++ b/web/src/api/file.ts @@ -0,0 +1,185 @@ +/** + * 文件管理相关 API + */ +import request from './request' + +/** 文件信息 */ +export interface FileInfo { + id: number + name: string + extension: string + folder_id: number + size: number + mime_type: string + is_favorite: number + download_count: number + created_at: string + updated_at: string +} + +/** 文件夹信息 */ +export interface FolderInfo { + id: number + name: string + parent_id: number + path: string + created_at: string + updated_at: string +} + +/** 文件列表响应 */ +export interface FileListResult { + folders: FolderInfo[] + files: FileInfo[] +} + +/** 分享信息 */ +export interface ShareInfo { + id: number + share_code: string + share_url: string + has_password: boolean + expire_at: string | null + max_download: number + download_count: number + created_at: string +} + +/** + * 上传文件 + * @param file 文件对象 + * @param folderId 目标文件夹ID, 0=根目录 + */ +export function uploadFile(file: File, folderId: number = 0) { + const formData = new FormData() + formData.append('file', file) + return request.post(`/file/upload?folder_id=${folderId}`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) +} + +/** + * 获取文件夹内容(文件+子文件夹) + */ +export function getFolderChildren(folderId: number | string) { + return request.get(`/folder/${folderId}/children`) +} + +/** + * 创建文件夹 + */ +export function createFolder(name: string, parentId: number = 0) { + return request.post('/folder', { name, parent_id: parentId }) +} + +/** + * 重命名文件 + */ +export function renameFile(fileId: number, newName: string) { + return request.put(`/file/${fileId}/rename`, { new_name: newName }) +} + +/** + * 重命名文件夹 + */ +export function renameFolder(folderId: number, newName: string) { + return request.put(`/folder/${folderId}/rename`, { new_name: newName }) +} + +/** + * 删除文件(移入回收站) + */ +export function deleteFile(fileId: number) { + return request.delete(`/file/${fileId}`) +} + +/** + * 删除文件夹(移入回收站) + */ +export function deleteFolder(folderId: number) { + return request.delete(`/folder/${folderId}`) +} + +/** + * 移动文件 + */ +export function moveFile(fileId: number, targetFolderId: number) { + return request.post(`/file/${fileId}/move`, { target_folder_id: targetFolderId }) +} + +/** + * 搜索文件 + */ +export function searchFiles(keyword: string) { + return request.get('/file/search', { params: { keyword } }) +} + +/** + * 获取回收站列表 + */ +export function getTrash() { + return request.get('/file/trash') +} + +/** + * 恢复文件 + */ +export function restoreFile(fileId: number) { + return request.post(`/file/${fileId}/restore`) +} + +/** + * 永久删除文件 + */ +export function permanentDeleteFile(fileId: number) { + return request.delete(`/file/${fileId}/permanent`) +} + +/** + * 获取文件下载链接 + */ +export function getDownloadUrl(fileId: number): string { + return `/api/file/${fileId}/download` +} + +/** + * 创建分享 + */ +export function createShare(fileId: number, password?: string, expireHours?: number, maxDownload?: number, allowPreview?: number, allowDownload?: number) { + return request.post('/share', { + file_id: fileId, + password, + expire_hours: expireHours, + max_download: maxDownload, + allow_preview: allowPreview, + allow_download: allowDownload, + }) +} + +/** + * 获取我的分享列表 + */ +export function getMyShares() { + return request.get('/share') +} + +/** + * 取消分享 + */ +export function cancelShare(shareId: number) { + return request.delete(`/share/${shareId}`) +} + +/** + * 获取分享信息(公开) + */ +export function getShareInfo(code: string) { + return request.get(`/s/${code}`) +} + +/** + * 验证分享密码 + */ +export function verifySharePassword(code: string, password: string) { + return request.post(`/s/${code}/verify`, { password }) +} diff --git a/web/src/api/request.ts b/web/src/api/request.ts new file mode 100644 index 0000000..e8cafe5 --- /dev/null +++ b/web/src/api/request.ts @@ -0,0 +1,83 @@ +/** + * Axios 请求封装 + * 统一处理请求/响应拦截、Token注入、错误提示 + */ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import router from '../router' + +// 创建 axios 实例 +const request = axios.create({ + baseURL: '/api', + timeout: 30000, +}) + +/** + * 请求拦截器 + * 自动注入 JWT Token 到请求头 + */ +request.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +/** + * 响应拦截器 + * 统一处理业务错误码, 401自动跳转登录 + */ +request.interceptors.response.use( + (response) => { + const data = response.data + + // 业务错误 + if (data.code !== 0) { + ElMessage.error(data.message || '请求失败') + return Promise.reject(new Error(data.message)) + } + + return data + }, + (error) => { + if (error.response) { + const { status, data } = error.response + + switch (status) { + case 401: { + // 只有之前有Token(已登录过)才提示"已过期" + const hadToken = !!localStorage.getItem('token') + localStorage.removeItem('token') + localStorage.removeItem('username') + localStorage.removeItem('nickname') + localStorage.removeItem('role') + if (hadToken) { + ElMessage.error('登录已过期, 请重新登录') + } + router.push('/login') + break + } + case 403: + ElMessage.error('没有权限执行此操作') + break + case 404: + ElMessage.error(data?.message || '资源不存在') + break + default: + ElMessage.error(data?.message || `请求失败 (${status})`) + } + } else { + ElMessage.error('网络错误, 请检查连接') + } + + return Promise.reject(error) + } +) + +export default request diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..059ab71 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,23 @@ +/** + * Vue3 应用入口 + * 初始化 Vue、路由、状态管理、UI组件库 + */ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import './assets/styles/global.css' + +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +app.use(createPinia()) // 状态管理 +app.use(router) // 路由 +app.use(ElementPlus, { // UI组件库(中文) + locale: zhCn, +}) + +app.mount('#app') diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..4b812b2 --- /dev/null +++ b/web/src/router/index.ts @@ -0,0 +1,66 @@ +/** + * 路由配置 + * 前台: 普通用户文件管理 + * 后台: 管理员系统管理 + */ +import { createRouter, createWebHistory } from 'vue-router' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + // ====== 前台(用户) ====== + { + path: '/login', + name: 'Login', + component: () => import('../views/Login.vue'), + meta: { requiresAuth: false }, + }, + { + path: '/', + name: 'FileManager', + component: () => import('../views/FileManager.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/s/:code', + name: 'ShareView', + component: () => import('../views/ShareView.vue'), + meta: { requiresAuth: false }, + }, + + // ====== 后台(管理员) ====== + { + path: '/admin', + name: 'AdminLayout', + component: () => import('../views/admin/AdminLayout.vue'), + meta: { requiresAuth: true, requiresAdmin: true }, + redirect: '/admin/dashboard', + children: [ + { path: 'dashboard', name: 'AdminDashboard', component: () => import('../views/admin/Dashboard.vue') }, + { path: 'users', name: 'AdminUsers', component: () => import('../views/admin/UserManage.vue') }, + { path: 'roles', name: 'AdminRoles', component: () => import('../views/admin/RoleManage.vue') }, + { path: 'org', name: 'AdminOrg', component: () => import('../views/admin/OrganizationManage.vue') }, + { path: 'storage', name: 'AdminStorage', component: () => import('../views/admin/StoragePolicyManage.vue') }, + { 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: 'blacklist', name: 'AdminBlacklist', component: () => import('../views/admin/IPBlacklist.vue') }, + { path: 'backup', name: 'AdminBackup', component: () => import('../views/admin/BackupManage.vue') }, + ], + }, + ], +}) + +router.beforeEach((to, _from, next) => { + const token = localStorage.getItem('token') + + if (to.meta.requiresAuth && !token) { + next('/login') + } else if (to.path === '/login' && token) { + next('/') + } else { + next() + } +}) + +export default router diff --git a/web/src/stores/file.ts b/web/src/stores/file.ts new file mode 100644 index 0000000..db280c1 --- /dev/null +++ b/web/src/stores/file.ts @@ -0,0 +1,93 @@ +/** + * 文件管理状态管理 + * 管理当前目录路径、文件列表、选中状态 + */ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { getFolderChildren, type FileListResult, type FileInfo, type FolderInfo } from '../api/file' + +/** 面包屑路径项 */ +export interface PathItem { + id: number + name: string +} + +export const useFileStore = defineStore('file', () => { + // 当前文件夹ID (0=根目录) + const currentFolderId = ref(0) + + // 面包屑路径 + const breadcrumbs = ref([{ id: 0, name: '根目录' }]) + + // 文件列表数据 + const folders = ref([]) + const files = ref([]) + + // 加载状态 + const loading = ref(false) + + /** + * 加载指定文件夹的内容 + */ + async function loadFolder(folderId: number = 0) { + loading.value = true + try { + const res = await getFolderChildren(folderId === 0 ? 'root' : folderId) + const data: FileListResult = res.data + + currentFolderId.value = folderId + folders.value = data.folders || [] + files.value = data.files || [] + } catch (error) { + console.error('加载文件列表失败:', error) + } finally { + loading.value = false + } + } + + /** + * 进入子文件夹 + * 更新面包屑路径 + */ + function enterFolder(folderId: number, folderName: string) { + breadcrumbs.value.push({ id: folderId, name: folderName }) + loadFolder(folderId) + } + + /** + * 导航到面包屑指定位置 + */ + function navigateTo(index: number) { + const item = breadcrumbs.value[index] + breadcrumbs.value = breadcrumbs.value.slice(0, index + 1) + loadFolder(item.id) + } + + /** + * 返回根目录 + */ + function goRoot() { + breadcrumbs.value = [{ id: 0, name: '根目录' }] + loadFolder(0) + } + + /** + * 刷新当前目录 + */ + function refresh() { + loadFolder(currentFolderId.value) + } + + return { + currentFolderId, + breadcrumbs, + folders, + files, + loading, + loadFolder, + enterFolder, + navigateTo, + goRoot, + refresh, + } +}) diff --git a/web/src/stores/user.ts b/web/src/stores/user.ts new file mode 100644 index 0000000..d832718 --- /dev/null +++ b/web/src/stores/user.ts @@ -0,0 +1,126 @@ +/** + * 用户状态管理 + * 管理登录状态、用户信息、Token、权限 + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { login as loginApi, getUserPermissions, type UserPermissionVO, type LoginParams } from '../api/auth' +import router from '../router' + +export const useUserStore = defineStore('user', () => { + // 状态 + const token = ref(localStorage.getItem('token') || '') + const username = ref(localStorage.getItem('username') || '') + const nickname = ref(localStorage.getItem('nickname') || '') + const role = ref(localStorage.getItem('role') || '') + + // RBAC权限 + const roles = ref<{ id: number; name: string; display_name: string }[]>([]) + const permissions = ref([]) + + /** 是否已登录 */ + const isLoggedIn = () => !!token.value + + /** 是否管理员(兼容旧逻辑) */ + const isAdmin = () => role.value === 'admin' || roles.value.some(r => r.name === 'admin') + + /** + * 检查是否拥有指定权限 + * 管理员拥有所有权限 + */ + function hasPermission(code: string): boolean { + if (isAdmin()) return true + return permissions.value.includes(code) || permissions.value.includes('*') + } + + /** + * 登录 + */ + async function login(params: LoginParams) { + const res = await loginApi(params) + const data = res.data + saveLoginResult(data) + // 首次登录需改密码 + if ((data as any).must_change_pwd) { + router.push('/?change_pwd=1') + } else { + router.push('/') + } + } + + /** + * SSO登录(保存Token) + */ + function ssoLogin(data: { token: string; username: string; nickname: string; role: string }) { + saveLoginResult(data) + router.push('/') + } + + /** + * 保存登录结果到本地 + */ + function saveLoginResult(data: { token: string; username: string; nickname: string; role: string }) { + token.value = data.token + username.value = data.username + nickname.value = data.nickname + role.value = data.role + + localStorage.setItem('token', data.token) + localStorage.setItem('username', data.username) + localStorage.setItem('nickname', data.nickname) + localStorage.setItem('role', data.role) + + // 登录后加载权限 + loadPermissions() + } + + /** + * 加载用户权限 + */ + async function loadPermissions() { + if (!token.value) return + try { + const res = await getUserPermissions() + const data: UserPermissionVO = res.data + roles.value = data.roles || [] + permissions.value = data.permissions || [] + } catch (error) { + console.error('加载权限失败:', error) + } + } + + /** + * 登出 + */ + function logout() { + token.value = '' + username.value = '' + nickname.value = '' + role.value = '' + roles.value = [] + permissions.value = [] + + localStorage.removeItem('token') + localStorage.removeItem('username') + localStorage.removeItem('nickname') + localStorage.removeItem('role') + + router.push('/login') + } + + return { + token, + username, + nickname, + role, + roles, + permissions, + isLoggedIn, + isAdmin, + hasPermission, + login, + ssoLogin, + loadPermissions, + logout, + } +}) diff --git a/web/src/views/FileManager.vue b/web/src/views/FileManager.vue new file mode 100644 index 0000000..57c7580 --- /dev/null +++ b/web/src/views/FileManager.vue @@ -0,0 +1,628 @@ + + + + + diff --git a/web/src/views/Login.vue b/web/src/views/Login.vue new file mode 100644 index 0000000..c31dc7d --- /dev/null +++ b/web/src/views/Login.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/web/src/views/ShareView.vue b/web/src/views/ShareView.vue new file mode 100644 index 0000000..e928343 --- /dev/null +++ b/web/src/views/ShareView.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/web/src/views/admin/OrganizationManage.vue b/web/src/views/admin/OrganizationManage.vue new file mode 100644 index 0000000..1527d40 --- /dev/null +++ b/web/src/views/admin/OrganizationManage.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/web/src/views/admin/RoleManage.vue b/web/src/views/admin/RoleManage.vue new file mode 100644 index 0000000..53544f9 --- /dev/null +++ b/web/src/views/admin/RoleManage.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/web/src/views/admin/StoragePolicyManage.vue b/web/src/views/admin/StoragePolicyManage.vue new file mode 100644 index 0000000..2a2ff57 --- /dev/null +++ b/web/src/views/admin/StoragePolicyManage.vue @@ -0,0 +1,229 @@ + + + diff --git a/web/src/views/admin/UserManage.vue b/web/src/views/admin/UserManage.vue new file mode 100644 index 0000000..d6ef7f0 --- /dev/null +++ b/web/src/views/admin/UserManage.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..072649b --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..713ba4a --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } 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, + }, + }, + }, +})